mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
feat: Enhance TimeEntry audit logging with comprehensive tracking
Add comprehensive audit logging for TimeEntry operations including: - Client/project context and creation timestamps - Full entity state before/after changes - User-provided reasons for deletions and modifications - Enhanced UI for entering reasons in delete/edit dialogs Database Changes: - Add migration 114: reason, entity_metadata, full_old_state, full_new_state columns - Use JSON column type for entity_metadata for better type handling Model Updates: - Extend AuditLog model with new fields and helper methods - Update log_change() to accept reason, metadata, and full states - Add get_entity_metadata(), get_full_old_state(), get_full_new_state() methods - Use JSON column for entity_metadata (returns dict/list directly) Service Layer: - Update TimeTrackingService to capture full TimeEntry state and metadata - Accept reason parameter in delete_entry() and update_entry() - Create comprehensive audit logs with all context API Routes: - Update api.py, api_v1.py, and timer.py routes to accept reason parameter - Refactor routes to use service layer for consistent audit logging - Add reason support to bulk delete operations UI Enhancements: - Add reason textarea to bulk delete confirmation dialog - Add reason textarea to time entry edit forms (admin and regular users) - Update JavaScript to handle reason submission Audit Log Display: - Show client/project information and creation timestamp in list view - Display full old/new states, reason, and metadata in detail view - Format JSON states for better readability Bug Fixes: - Fix duration_seconds reference in timer stop route - Improve error handling in timer operations with proper exception handling - Add dashboard cache invalidation after manual entry creation
This commit is contained in:
@@ -45,6 +45,18 @@ class AuditLog(db.Model):
|
||||
# Human-readable change description
|
||||
change_description = db.Column(db.Text, nullable=True)
|
||||
|
||||
# User-provided reason for change/deletion
|
||||
reason = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Entity metadata (JSON-encoded for client_id, project_id, created_at, etc.)
|
||||
entity_metadata = db.Column(db.JSON, nullable=True) # JSON metadata (dict/list)
|
||||
|
||||
# Full entity state before change (JSON-encoded)
|
||||
full_old_state = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Full entity state after change (JSON-encoded)
|
||||
full_new_state = db.Column(db.Text, nullable=True)
|
||||
|
||||
# Additional context
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.Text, nullable=True)
|
||||
@@ -79,6 +91,10 @@ class AuditLog(db.Model):
|
||||
new_value=None,
|
||||
entity_name=None,
|
||||
change_description=None,
|
||||
reason=None,
|
||||
entity_metadata=None,
|
||||
full_old_state=None,
|
||||
full_new_state=None,
|
||||
ip_address=None,
|
||||
user_agent=None,
|
||||
request_path=None,
|
||||
@@ -95,6 +111,10 @@ class AuditLog(db.Model):
|
||||
new_value: New value (will be JSON-encoded)
|
||||
entity_name: Cached name of the entity for display
|
||||
change_description: Human-readable description of the change
|
||||
reason: User-provided reason for the change/deletion
|
||||
entity_metadata: Metadata dict/list (client_id, project_id, created_at, etc.) - will be stored as JSON
|
||||
full_old_state: JSON-encoded full entity state before change
|
||||
full_new_state: JSON-encoded full entity state after change
|
||||
ip_address: IP address of the request
|
||||
user_agent: User agent string
|
||||
request_path: Path of the request that triggered the change
|
||||
@@ -102,6 +122,11 @@ class AuditLog(db.Model):
|
||||
# Encode values as JSON if they're not already strings
|
||||
old_val_str = cls._encode_value(old_value)
|
||||
new_val_str = cls._encode_value(new_value)
|
||||
|
||||
# entity_metadata is stored as JSON type, so pass dict/list directly (not encoded)
|
||||
# full_old_state and full_new_state are Text columns, so encode as JSON strings
|
||||
full_old_str = cls._encode_value(full_old_state) if full_old_state else None
|
||||
full_new_str = cls._encode_value(full_new_state) if full_new_state else None
|
||||
|
||||
audit_log = cls(
|
||||
user_id=user_id,
|
||||
@@ -113,6 +138,10 @@ class AuditLog(db.Model):
|
||||
new_value=new_val_str,
|
||||
entity_name=entity_name,
|
||||
change_description=change_description,
|
||||
reason=reason,
|
||||
entity_metadata=entity_metadata, # Pass dict/list directly for JSON column
|
||||
full_old_state=full_old_str,
|
||||
full_new_state=full_new_str,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_path=request_path,
|
||||
@@ -178,6 +207,22 @@ class AuditLog(db.Model):
|
||||
"""Get the decoded new value"""
|
||||
return self._decode_value(self.new_value)
|
||||
|
||||
def get_entity_metadata(self):
|
||||
"""Get the entity metadata (already a dict/list for JSON column)"""
|
||||
# For JSON columns, SQLAlchemy returns the dict/list directly
|
||||
# For backward compatibility with Text columns, try to decode if it's a string
|
||||
if isinstance(self.entity_metadata, str):
|
||||
return self._decode_value(self.entity_metadata)
|
||||
return self.entity_metadata
|
||||
|
||||
def get_full_old_state(self):
|
||||
"""Get the decoded full old state"""
|
||||
return self._decode_value(self.full_old_state)
|
||||
|
||||
def get_full_new_state(self):
|
||||
"""Get the decoded full new state"""
|
||||
return self._decode_value(self.full_new_state)
|
||||
|
||||
@classmethod
|
||||
def get_for_entity(cls, entity_type, entity_id, limit=100):
|
||||
"""Get audit logs for a specific entity"""
|
||||
@@ -224,6 +269,10 @@ class AuditLog(db.Model):
|
||||
"old_value": self.get_old_value(),
|
||||
"new_value": self.get_new_value(),
|
||||
"change_description": self.change_description,
|
||||
"reason": self.reason,
|
||||
"entity_metadata": self.get_entity_metadata(),
|
||||
"full_old_state": self.get_full_old_state(),
|
||||
"full_new_state": self.get_full_new_state(),
|
||||
"ip_address": self.ip_address,
|
||||
"user_agent": self.user_agent,
|
||||
"request_path": self.request_path,
|
||||
|
||||
@@ -136,11 +136,15 @@ class TimeEntry(db.Model):
|
||||
@property
|
||||
def duration_formatted(self):
|
||||
"""Get duration formatted as HH:MM:SS"""
|
||||
if not self.duration_seconds:
|
||||
# For active timers (end_time is None), use current_duration_seconds
|
||||
if not self.end_time:
|
||||
total_seconds = self.current_duration_seconds
|
||||
elif not self.duration_seconds:
|
||||
return "00:00:00"
|
||||
else:
|
||||
total_seconds = int(self.duration_seconds)
|
||||
|
||||
# Convert to int to ensure integer values for formatting
|
||||
total_seconds = int(self.duration_seconds)
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
|
||||
+47
-108
@@ -1418,15 +1418,7 @@ def update_entry(entry_id):
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Optional: project change (admin only)
|
||||
new_project_id = data.get("project_id")
|
||||
if new_project_id is not None and current_user.is_admin:
|
||||
if new_project_id != entry.project_id:
|
||||
project = Project.query.filter_by(id=new_project_id, status="active").first()
|
||||
if not project:
|
||||
return jsonify({"error": "Invalid project"}), 400
|
||||
entry.project_id = new_project_id
|
||||
reason = data.get("reason") # Optional reason for the change
|
||||
|
||||
# Optional: start/end time updates (admin only for safety)
|
||||
# Accept HTML datetime-local format: YYYY-MM-DDTHH:MM
|
||||
@@ -1445,79 +1437,43 @@ def update_entry(entry_id):
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if current_user.is_admin:
|
||||
start_time_str = data.get("start_time")
|
||||
end_time_str = data.get("end_time")
|
||||
|
||||
if start_time_str:
|
||||
parsed_start = parse_dt_local(start_time_str)
|
||||
if not parsed_start:
|
||||
return jsonify({"error": "Invalid start time format"}), 400
|
||||
entry.start_time = parsed_start
|
||||
|
||||
if end_time_str is not None:
|
||||
if end_time_str == "" or end_time_str is False:
|
||||
entry.end_time = None
|
||||
entry.duration_seconds = None
|
||||
else:
|
||||
parsed_end = parse_dt_local(end_time_str)
|
||||
if not parsed_end:
|
||||
return jsonify({"error": "Invalid end time format"}), 400
|
||||
if parsed_end <= (entry.start_time or parsed_end):
|
||||
return jsonify({"error": "End time must be after start time"}), 400
|
||||
entry.end_time = parsed_end
|
||||
# Recalculate duration
|
||||
entry.calculate_duration()
|
||||
|
||||
# Prevent multiple active timers for the same user when editing
|
||||
if entry.end_time is None:
|
||||
conflict = (
|
||||
TimeEntry.query.filter(TimeEntry.user_id == entry.user_id)
|
||||
.filter(TimeEntry.end_time.is_(None))
|
||||
.filter(TimeEntry.id != entry.id)
|
||||
.first()
|
||||
)
|
||||
if conflict:
|
||||
return jsonify({"error": "User already has an active timer"}), 400
|
||||
|
||||
# Notes, tags, billable (both admin and owner can change)
|
||||
if "notes" in data:
|
||||
entry.notes = data["notes"].strip() if data["notes"] else None
|
||||
|
||||
if "tags" in data:
|
||||
entry.tags = data["tags"].strip() if data["tags"] else None
|
||||
|
||||
if "billable" in data:
|
||||
entry.billable = bool(data["billable"])
|
||||
|
||||
if "paid" in data:
|
||||
entry.paid = bool(data["paid"])
|
||||
# Clear invoice number if marking as unpaid
|
||||
if not entry.paid:
|
||||
entry.invoice_number = None
|
||||
|
||||
if "invoice_number" in data:
|
||||
invoice_number = data["invoice_number"]
|
||||
entry.invoice_number = invoice_number.strip() if invoice_number else None
|
||||
|
||||
# Prefer local time for updated_at per project preference
|
||||
entry.updated_at = local_now()
|
||||
|
||||
if not safe_commit("api_update_entry", {"entry_id": entry_id}):
|
||||
return jsonify({"error": "Database error while updating entry"}), 500
|
||||
# Use service layer for update to get enhanced audit logging
|
||||
from app.services import TimeTrackingService
|
||||
service = TimeTrackingService()
|
||||
|
||||
# Convert data to service parameters
|
||||
result = service.update_entry(
|
||||
entry_id=entry_id,
|
||||
user_id=current_user.id,
|
||||
is_admin=current_user.is_admin,
|
||||
project_id=data.get("project_id") if current_user.is_admin else None,
|
||||
client_id=data.get("client_id") if current_user.is_admin else None,
|
||||
task_id=data.get("task_id"),
|
||||
start_time=parse_dt_local(data.get("start_time")) if current_user.is_admin and data.get("start_time") else None,
|
||||
end_time=parse_dt_local(data.get("end_time")) if current_user.is_admin and data.get("end_time") else None,
|
||||
notes=data.get("notes"),
|
||||
tags=data.get("tags"),
|
||||
billable=data.get("billable"),
|
||||
paid=data.get("paid"),
|
||||
invoice_number=data.get("invoice_number"),
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not update entry")}), 400
|
||||
|
||||
# Invalidate dashboard cache for the entry owner so changes appear immediately
|
||||
try:
|
||||
from app.utils.cache import get_cache
|
||||
cache = get_cache()
|
||||
cache_key = f"dashboard:{entry.user_id}"
|
||||
cache_key = f"dashboard:{result['entry'].user_id}"
|
||||
cache.delete(cache_key)
|
||||
current_app.logger.debug("Invalidated dashboard cache for user %s after entry update", entry.user_id)
|
||||
current_app.logger.debug("Invalidated dashboard cache for user %s after entry update", result['entry'].user_id)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
|
||||
|
||||
payload = entry.to_dict()
|
||||
payload["project_name"] = entry.project.name if entry.project else None
|
||||
payload = result["entry"].to_dict()
|
||||
payload["project_name"] = result["entry"].project.name if result["entry"].project else None
|
||||
return jsonify({"success": True, "entry": payload})
|
||||
|
||||
|
||||
@@ -1525,50 +1481,33 @@ def update_entry(entry_id):
|
||||
@login_required
|
||||
def delete_entry(entry_id):
|
||||
"""Delete a time entry"""
|
||||
entry = TimeEntry.query.get_or_404(entry_id)
|
||||
|
||||
# Check permissions
|
||||
if entry.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({"error": "Access denied"}), 403
|
||||
|
||||
# Don't allow deletion of active timers
|
||||
if entry.is_active:
|
||||
return jsonify({"error": "Cannot delete active timer"}), 400
|
||||
|
||||
# Capture entry info for logging before deletion
|
||||
project_name = entry.project.name if entry.project else None
|
||||
client_name = entry.client.name if entry.client else None
|
||||
entity_name = project_name or client_name or "Unknown"
|
||||
duration_formatted = entry.duration_formatted
|
||||
entry_user_id = entry.user_id # Capture user_id before deletion
|
||||
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
data = request.get_json() or {}
|
||||
reason = data.get("reason") # Optional reason for deletion
|
||||
|
||||
# Use service layer for deletion to get enhanced audit logging
|
||||
from app.services import TimeTrackingService
|
||||
service = TimeTrackingService()
|
||||
|
||||
result = service.delete_entry(
|
||||
user_id=current_user.id,
|
||||
entry_id=entry_id,
|
||||
is_admin=current_user.is_admin,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete entry")}), 400
|
||||
|
||||
# Invalidate dashboard cache for the entry owner so changes appear immediately
|
||||
try:
|
||||
from app.utils.cache import get_cache
|
||||
cache = get_cache()
|
||||
cache_key = f"dashboard:{entry_user_id}"
|
||||
cache_key = f"dashboard:{current_user.id}"
|
||||
cache.delete(cache_key)
|
||||
current_app.logger.debug("Invalidated dashboard cache for user %s after entry deletion", entry_user_id)
|
||||
current_app.logger.debug("Invalidated dashboard cache for user %s after entry deletion", current_user.id)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
|
||||
|
||||
# Log activity
|
||||
from app.models import Activity
|
||||
Activity.log(
|
||||
user_id=current_user.id,
|
||||
action="deleted",
|
||||
entity_type="time_entry",
|
||||
entity_id=entry_id,
|
||||
entity_name=entity_name,
|
||||
description=f'Deleted time entry for {entity_name} - {duration_formatted}',
|
||||
extra_data={"project_name": project_name, "client_name": client_name, "duration_formatted": duration_formatted},
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
)
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
|
||||
+11
-1
@@ -716,6 +716,7 @@ def update_time_entry(entry_id):
|
||||
user_id=g.api_user.id,
|
||||
is_admin=g.api_user.is_admin,
|
||||
project_id=data.get("project_id"),
|
||||
client_id=data.get("client_id"),
|
||||
task_id=data.get("task_id"),
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
@@ -724,6 +725,7 @@ def update_time_entry(entry_id):
|
||||
billable=data.get("billable"),
|
||||
paid=data.get("paid"),
|
||||
invoice_number=data.get("invoice_number"),
|
||||
reason=data.get("reason"),
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
@@ -754,8 +756,16 @@ def delete_time_entry(entry_id):
|
||||
"""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
data = request.get_json() or {}
|
||||
reason = data.get("reason") # Optional reason for deletion
|
||||
|
||||
time_tracking_service = TimeTrackingService()
|
||||
result = time_tracking_service.delete_entry(entry_id=entry_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin)
|
||||
result = time_tracking_service.delete_entry(
|
||||
entry_id=entry_id,
|
||||
user_id=g.api_user.id,
|
||||
is_admin=g.api_user.is_admin,
|
||||
reason=reason,
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("message", "Could not delete time entry")}), 400
|
||||
|
||||
+103
-67
@@ -418,7 +418,7 @@ def stop_timer():
|
||||
current_app.logger.info("Stopped timer id=%s for user=%s", active_timer.id, current_user.username)
|
||||
|
||||
# Track timer stopped event
|
||||
duration_seconds = active_timer.duration if hasattr(active_timer, "duration") else 0
|
||||
duration_seconds = active_timer.duration_seconds if active_timer.duration_seconds else 0
|
||||
log_event(
|
||||
"timer.stopped",
|
||||
user_id=current_user.id,
|
||||
@@ -467,30 +467,37 @@ def stop_timer():
|
||||
current_user.id,
|
||||
{"source": "timer", "duration_seconds": duration_seconds, "has_task": bool(active_timer.task_id)},
|
||||
)
|
||||
|
||||
# Emit WebSocket event for real-time updates
|
||||
try:
|
||||
socketio.emit(
|
||||
"timer_stopped",
|
||||
{"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted},
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Socket emit failed for timer_stopped: %s", e)
|
||||
|
||||
# Invalidate dashboard cache so timer disappears immediately
|
||||
try:
|
||||
from app.utils.cache import get_cache
|
||||
cache = get_cache()
|
||||
cache_key = f"dashboard:{current_user.id}"
|
||||
cache.delete(cache_key)
|
||||
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
|
||||
|
||||
flash(f"Timer stopped. Duration: {active_timer.duration_formatted}", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
except ValueError as e:
|
||||
# Timer already stopped or invalid state
|
||||
current_app.logger.warning("Cannot stop timer: %s", e)
|
||||
flash(_("Cannot stop timer: %(error)s", error=str(e)), "error")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
except Exception as e:
|
||||
current_app.logger.exception("Error stopping timer: %s", e)
|
||||
|
||||
# Emit WebSocket event for real-time updates
|
||||
try:
|
||||
socketio.emit(
|
||||
"timer_stopped",
|
||||
{"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted},
|
||||
)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Socket emit failed for timer_stopped: %s", e)
|
||||
|
||||
# Invalidate dashboard cache so timer disappears immediately
|
||||
try:
|
||||
from app.utils.cache import get_cache
|
||||
cache = get_cache()
|
||||
cache_key = f"dashboard:{current_user.id}"
|
||||
cache.delete(cache_key)
|
||||
current_app.logger.debug("Invalidated dashboard cache for user %s", current_user.id)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
|
||||
|
||||
flash(f"Timer stopped. Duration: {active_timer.duration_formatted}", "success")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
flash(_("Could not stop timer due to an error. Please try again or contact support if the problem persists."), "error")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
|
||||
@timer_bp.route("/timer/status")
|
||||
@@ -529,18 +536,31 @@ def edit_timer(timer_id):
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
if request.method == "POST":
|
||||
# Update timer details
|
||||
timer.notes = request.form.get("notes", "").strip()
|
||||
timer.tags = request.form.get("tags", "").strip()
|
||||
timer.billable = request.form.get("billable") == "on"
|
||||
timer.paid = request.form.get("paid") == "on"
|
||||
# Get reason for change
|
||||
reason = request.form.get("reason", "").strip() or None
|
||||
|
||||
# Use service layer for update to get enhanced audit logging
|
||||
from app.services import TimeTrackingService
|
||||
service = TimeTrackingService()
|
||||
|
||||
# Prepare update parameters
|
||||
update_params = {
|
||||
"entry_id": timer_id,
|
||||
"user_id": current_user.id,
|
||||
"is_admin": current_user.is_admin,
|
||||
"notes": request.form.get("notes", "").strip() or None,
|
||||
"tags": request.form.get("tags", "").strip() or None,
|
||||
"billable": request.form.get("billable") == "on",
|
||||
"paid": request.form.get("paid") == "on",
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
# Update invoice number
|
||||
invoice_number = request.form.get("invoice_number", "").strip()
|
||||
timer.invoice_number = invoice_number if invoice_number else None
|
||||
update_params["invoice_number"] = invoice_number if invoice_number else None
|
||||
# Clear invoice number if marking as unpaid
|
||||
if not timer.paid:
|
||||
timer.invoice_number = None
|
||||
if update_params["paid"] is False:
|
||||
update_params["invoice_number"] = None
|
||||
|
||||
# Admin users can edit additional fields
|
||||
if current_user.is_admin:
|
||||
@@ -549,7 +569,7 @@ def edit_timer(timer_id):
|
||||
if new_project_id and new_project_id != timer.project_id:
|
||||
new_project = Project.query.filter_by(id=new_project_id, status="active").first()
|
||||
if new_project:
|
||||
timer.project_id = new_project_id
|
||||
update_params["project_id"] = new_project_id
|
||||
else:
|
||||
flash(_("Invalid project selected"), "error")
|
||||
return render_template(
|
||||
@@ -562,14 +582,16 @@ def edit_timer(timer_id):
|
||||
else Task.query.filter_by(project_id=new_project_id).order_by(Task.name).all()
|
||||
),
|
||||
)
|
||||
else:
|
||||
update_params["project_id"] = None # Don't change if not provided
|
||||
|
||||
# Update task if changed
|
||||
new_task_id = request.form.get("task_id", type=int)
|
||||
if new_task_id != timer.task_id:
|
||||
if new_task_id:
|
||||
new_task = Task.query.filter_by(id=new_task_id, project_id=timer.project_id).first()
|
||||
new_task = Task.query.filter_by(id=new_task_id, project_id=update_params.get("project_id") or timer.project_id).first()
|
||||
if new_task:
|
||||
timer.task_id = new_task_id
|
||||
update_params["task_id"] = new_task_id
|
||||
else:
|
||||
flash(_("Invalid task selected for the chosen project"), "error")
|
||||
return render_template(
|
||||
@@ -579,7 +601,9 @@ def edit_timer(timer_id):
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
|
||||
)
|
||||
else:
|
||||
timer.task_id = None
|
||||
update_params["task_id"] = None
|
||||
else:
|
||||
update_params["task_id"] = None # Don't change if not provided
|
||||
|
||||
# Update start and end times if provided
|
||||
start_date = request.form.get("start_date")
|
||||
@@ -606,7 +630,7 @@ def edit_timer(timer_id):
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
|
||||
)
|
||||
|
||||
timer.start_time = new_start_time
|
||||
update_params["start_time"] = new_start_time
|
||||
except ValueError:
|
||||
flash(_("Invalid start date/time format"), "error")
|
||||
return render_template(
|
||||
@@ -615,6 +639,8 @@ def edit_timer(timer_id):
|
||||
projects=Project.query.filter_by(status="active").order_by(Project.name).all(),
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
|
||||
)
|
||||
else:
|
||||
update_params["start_time"] = None
|
||||
|
||||
if end_date and end_time:
|
||||
try:
|
||||
@@ -623,7 +649,8 @@ def edit_timer(timer_id):
|
||||
new_end_time = utc_to_local(parsed_end_utc).replace(tzinfo=None)
|
||||
|
||||
# Validate that end time is after start time
|
||||
if new_end_time <= timer.start_time:
|
||||
start_time_for_validation = update_params.get("start_time") or timer.start_time
|
||||
if new_end_time <= start_time_for_validation:
|
||||
flash(_("End time must be after start time"), "error")
|
||||
return render_template(
|
||||
"timer/edit_timer.html",
|
||||
@@ -632,9 +659,7 @@ def edit_timer(timer_id):
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
|
||||
)
|
||||
|
||||
timer.end_time = new_end_time
|
||||
# Recalculate duration
|
||||
timer.calculate_duration()
|
||||
update_params["end_time"] = new_end_time
|
||||
except ValueError:
|
||||
flash(_("Invalid end date/time format"), "error")
|
||||
return render_template(
|
||||
@@ -643,15 +668,20 @@ def edit_timer(timer_id):
|
||||
projects=Project.query.filter_by(status="active").order_by(Project.name).all(),
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all(),
|
||||
)
|
||||
else:
|
||||
update_params["end_time"] = None
|
||||
|
||||
# Update source if provided
|
||||
new_source = request.form.get("source")
|
||||
if new_source in ["manual", "auto"]:
|
||||
timer.source = new_source
|
||||
|
||||
if not safe_commit("edit_timer", {"timer_id": timer.id}):
|
||||
flash(_("Could not update timer due to a database error. Please check server logs."), "error")
|
||||
return redirect(url_for("main.dashboard"))
|
||||
# Call service layer to update
|
||||
result = service.update_entry(**update_params)
|
||||
|
||||
if not result.get("success"):
|
||||
flash(_(result.get("message", "Could not update timer")), "error")
|
||||
return render_template(
|
||||
"timer/edit_timer.html",
|
||||
timer=timer,
|
||||
projects=Project.query.filter_by(status="active").order_by(Project.name).all() if current_user.is_admin else [],
|
||||
tasks=Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all() if current_user.is_admin and timer.project_id else [],
|
||||
)
|
||||
|
||||
# Invalidate dashboard cache for the timer owner so changes appear immediately
|
||||
try:
|
||||
@@ -830,9 +860,10 @@ def delete_timer(timer_id):
|
||||
@login_required
|
||||
def bulk_delete_time_entries():
|
||||
"""Bulk delete time entries"""
|
||||
from app.utils.db import safe_commit
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
entry_ids = request.form.getlist("entry_ids[]")
|
||||
reason = request.form.get("reason", "").strip() or None # Optional reason for bulk deletion
|
||||
|
||||
if not entry_ids:
|
||||
flash(_("No time entries selected"), "warning")
|
||||
@@ -855,6 +886,9 @@ def bulk_delete_time_entries():
|
||||
deleted_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
# Use service layer for proper audit logging
|
||||
service = TimeTrackingService()
|
||||
|
||||
for entry in entries:
|
||||
# Check permissions
|
||||
if not can_view_all and entry.user_id != current_user.id:
|
||||
@@ -866,28 +900,20 @@ def bulk_delete_time_entries():
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Delete the entry
|
||||
db.session.delete(entry)
|
||||
deleted_count += 1
|
||||
|
||||
# Log activity
|
||||
Activity.log(
|
||||
# Delete using service layer to get enhanced audit logging
|
||||
result = service.delete_entry(
|
||||
user_id=current_user.id,
|
||||
action="deleted",
|
||||
entity_type="time_entry",
|
||||
entity_id=entry.id,
|
||||
entity_name=f"Time entry #{entry.id}",
|
||||
description=f"Deleted time entry",
|
||||
extra_data={"project_id": entry.project_id, "client_id": entry.client_id, "duration": entry.duration_formatted},
|
||||
ip_address=request.remote_addr,
|
||||
user_agent=request.headers.get("User-Agent"),
|
||||
entry_id=entry.id,
|
||||
is_admin=current_user.is_admin,
|
||||
reason=reason, # Use same reason for all entries in bulk delete
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
deleted_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
if deleted_count > 0:
|
||||
if not safe_commit("bulk_delete_time_entries", {"count": deleted_count}):
|
||||
flash(_("Could not delete time entries due to a database error. Please check server logs."), "error")
|
||||
return redirect(url_for("timer.time_entries_overview"))
|
||||
|
||||
flash(
|
||||
_("Successfully deleted %(count)d time entry/entries", count=deleted_count),
|
||||
"success"
|
||||
@@ -1105,6 +1131,16 @@ def manual_entry():
|
||||
else:
|
||||
flash(_("Manual entry created for %(target)s", target=target_name), "success")
|
||||
|
||||
# Invalidate dashboard cache so new entry appears immediately
|
||||
try:
|
||||
from app.utils.cache import get_cache
|
||||
cache = get_cache()
|
||||
cache_key = f"dashboard:{current_user.id}"
|
||||
cache.delete(cache_key)
|
||||
current_app.logger.debug("Invalidated dashboard cache for user %s after manual entry creation", current_user.id)
|
||||
except Exception as e:
|
||||
current_app.logger.warning("Failed to invalidate dashboard cache: %s", e)
|
||||
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return render_template(
|
||||
|
||||
@@ -301,10 +301,27 @@ class TimeTrackingService:
|
||||
billable: Optional[bool] = None,
|
||||
paid: Optional[bool] = None,
|
||||
invoice_number: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update a time entry.
|
||||
|
||||
Args:
|
||||
entry_id: ID of the time entry to update
|
||||
user_id: ID of the user performing the update
|
||||
is_admin: Whether the user is an admin
|
||||
project_id: Optional new project ID
|
||||
client_id: Optional new client ID
|
||||
task_id: Optional new task ID
|
||||
start_time: Optional new start time
|
||||
end_time: Optional new end time
|
||||
notes: Optional new notes
|
||||
tags: Optional new tags
|
||||
billable: Optional new billable status
|
||||
paid: Optional new paid status
|
||||
invoice_number: Optional new invoice number
|
||||
reason: Optional reason for the change
|
||||
|
||||
Returns:
|
||||
dict with 'success', 'message', and 'entry' keys
|
||||
"""
|
||||
@@ -325,6 +342,11 @@ class TimeTrackingService:
|
||||
"error": "timer_active",
|
||||
}
|
||||
|
||||
# Capture old state before changes
|
||||
from app.utils.audit import capture_timeentry_state, capture_timeentry_metadata
|
||||
full_old_state = capture_timeentry_state(entry)
|
||||
entity_metadata = capture_timeentry_metadata(entry)
|
||||
|
||||
# Update fields
|
||||
if project_id is not None:
|
||||
# Validate project
|
||||
@@ -384,12 +406,52 @@ class TimeTrackingService:
|
||||
"error": "database_error",
|
||||
}
|
||||
|
||||
# Capture new state after changes and create comprehensive audit log
|
||||
try:
|
||||
# Refresh entry to get updated values
|
||||
db.session.refresh(entry)
|
||||
full_new_state = capture_timeentry_state(entry)
|
||||
updated_metadata = capture_timeentry_metadata(entry)
|
||||
|
||||
from app.models.audit_log import AuditLog
|
||||
from app.utils.audit import get_request_info
|
||||
ip_address, user_agent, request_path = get_request_info()
|
||||
|
||||
entity_name = entry.project.name if entry.project else (entry.client.name if entry.client else "Unknown")
|
||||
|
||||
AuditLog.log_change(
|
||||
user_id=user_id,
|
||||
action="updated",
|
||||
entity_type="TimeEntry",
|
||||
entity_id=entry_id,
|
||||
entity_name=entity_name,
|
||||
change_description=f"Updated time entry for {entity_name}",
|
||||
reason=reason,
|
||||
entity_metadata=updated_metadata,
|
||||
full_old_state=full_old_state,
|
||||
full_new_state=full_new_state,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_path=request_path,
|
||||
)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
# Don't fail update if audit logging fails
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to create audit log for TimeEntry update: {e}")
|
||||
|
||||
return {"success": True, "message": "Time entry updated successfully", "entry": entry}
|
||||
|
||||
def delete_entry(self, user_id: int, entry_id: int, is_admin: bool = False) -> Dict[str, Any]:
|
||||
def delete_entry(self, user_id: int, entry_id: int, is_admin: bool = False, reason: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a time entry.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user performing the deletion
|
||||
entry_id: ID of the time entry to delete
|
||||
is_admin: Whether the user is an admin
|
||||
reason: Optional reason for deletion
|
||||
|
||||
Returns:
|
||||
dict with 'success' and 'message' keys
|
||||
"""
|
||||
@@ -416,11 +478,41 @@ class TimeTrackingService:
|
||||
entity_name = project_name or client_name or "Unknown"
|
||||
duration_formatted = entry.duration_formatted
|
||||
|
||||
# Capture full state and metadata for audit logging
|
||||
from app.utils.audit import capture_timeentry_state, capture_timeentry_metadata, get_request_info
|
||||
from app.models.audit_log import AuditLog
|
||||
|
||||
full_old_state = capture_timeentry_state(entry)
|
||||
entity_metadata = capture_timeentry_metadata(entry)
|
||||
ip_address, user_agent, request_path = get_request_info()
|
||||
|
||||
if self.time_entry_repo.delete(entry):
|
||||
if safe_commit("delete_entry", {"user_id": user_id, "entry_id": entry_id}):
|
||||
# Create comprehensive audit log entry
|
||||
try:
|
||||
AuditLog.log_change(
|
||||
user_id=user_id,
|
||||
action="deleted",
|
||||
entity_type="TimeEntry",
|
||||
entity_id=entry_id,
|
||||
entity_name=entity_name,
|
||||
change_description=f"Deleted time entry for {entity_name} - {duration_formatted}",
|
||||
reason=reason,
|
||||
entity_metadata=entity_metadata,
|
||||
full_old_state=full_old_state,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_path=request_path,
|
||||
)
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
# Don't fail deletion if audit logging fails
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to create audit log for TimeEntry deletion: {e}")
|
||||
|
||||
# Log activity
|
||||
from app.models import Activity
|
||||
from flask import request, has_request_context
|
||||
from flask import request
|
||||
Activity.log(
|
||||
user_id=user_id,
|
||||
action="deleted",
|
||||
@@ -428,9 +520,9 @@ class TimeTrackingService:
|
||||
entity_id=entry_id,
|
||||
entity_name=entity_name,
|
||||
description=f'Deleted time entry for {entity_name} - {duration_formatted}',
|
||||
extra_data={"project_name": project_name, "client_name": client_name, "duration_formatted": duration_formatted},
|
||||
ip_address=request.remote_addr if has_request_context() else None,
|
||||
user_agent=request.headers.get("User-Agent") if has_request_context() else None,
|
||||
extra_data={"project_name": project_name, "client_name": client_name, "duration_formatted": duration_formatted, "reason": reason},
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
)
|
||||
return {"success": True, "message": "Time entry deleted successfully"}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{{ badge(log.action, log.get_color()) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
<td class="px-6 py-4 text-sm text-gray-900 dark:text-gray-100">
|
||||
<a href="{{ url_for('audit_logs.entity_history', entity_type=log.entity_type, entity_id=log.entity_id) }}"
|
||||
class="text-primary hover:underline">
|
||||
{{ log.entity_type }}#{{ log.entity_id }}
|
||||
@@ -105,6 +105,30 @@
|
||||
{% if log.entity_name %}
|
||||
<br><span class="text-xs text-gray-500">{{ log.entity_name }}</span>
|
||||
{% endif %}
|
||||
{% if log.entity_type == 'TimeEntry' and log.get_entity_metadata() %}
|
||||
{% set metadata = log.get_entity_metadata() %}
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1 space-y-0.5">
|
||||
{% if metadata.project_name %}
|
||||
<div><i class="fas fa-project-diagram mr-1"></i>{{ _('Project') }}: {{ metadata.project_name }}{% if metadata.project_id %} (ID: {{ metadata.project_id }}){% endif %}</div>
|
||||
{% elif metadata.client_name %}
|
||||
<div><i class="fas fa-building mr-1"></i>{{ _('Client') }}: {{ metadata.client_name }}{% if metadata.client_id %} (ID: {{ metadata.client_id }}){% endif %}</div>
|
||||
{% endif %}
|
||||
{% if metadata.created_at %}
|
||||
<div><i class="fas fa-calendar-plus mr-1"></i>{{ _('Created') }}:
|
||||
{% if metadata.created_at is string %}
|
||||
{{ metadata.created_at[:16]|replace('T', ' ') }}
|
||||
{% else %}
|
||||
{{ metadata.created_at|user_datetime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if log.reason %}
|
||||
<div class="text-xs text-amber-600 dark:text-amber-400 mt-1">
|
||||
<i class="fas fa-comment-alt mr-1"></i><strong>{{ _('Reason') }}:</strong> {{ log.reason }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
|
||||
{% if log.field_name %}
|
||||
|
||||
@@ -71,13 +71,52 @@
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if audit_log.reason %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Reason') }}</dt>
|
||||
<dd class="mt-1 text-sm text-amber-600 dark:text-amber-400">
|
||||
<i class="fas fa-comment-alt mr-1"></i>{{ audit_log.reason }}
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if audit_log.entity_type == 'TimeEntry' and audit_log.get_entity_metadata() %}
|
||||
{% set metadata = audit_log.get_entity_metadata() %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ _('Entity Context') }}</dt>
|
||||
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
|
||||
<div class="space-y-1">
|
||||
{% if metadata.project_name %}
|
||||
<div><i class="fas fa-project-diagram mr-1"></i><strong>{{ _('Project') }}:</strong> {{ metadata.project_name }}{% if metadata.project_id %} (ID: {{ metadata.project_id }}){% endif %}</div>
|
||||
{% endif %}
|
||||
{% if metadata.client_name %}
|
||||
<div><i class="fas fa-building mr-1"></i><strong>{{ _('Client') }}:</strong> {{ metadata.client_name }}{% if metadata.client_id %} (ID: {{ metadata.client_id }}){% endif %}</div>
|
||||
{% endif %}
|
||||
{% if metadata.task_name %}
|
||||
<div><i class="fas fa-tasks mr-1"></i><strong>{{ _('Task') }}:</strong> {{ metadata.task_name }}{% if metadata.task_id %} (ID: {{ metadata.task_id }}){% endif %}</div>
|
||||
{% endif %}
|
||||
{% if metadata.created_at %}
|
||||
<div><i class="fas fa-calendar-plus mr-1"></i><strong>{{ _('TimeEntry Created') }}:</strong>
|
||||
{% if metadata.created_at is string %}
|
||||
{{ metadata.created_at[:19]|replace('T', ' ') }}
|
||||
{% else %}
|
||||
{{ metadata.created_at|user_datetime('%Y-%m-%d %H:%M:%S') }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if metadata.user_name %}
|
||||
<div><i class="fas fa-user mr-1"></i><strong>{{ _('Entry Owner') }}:</strong> {{ metadata.user_name }}{% if metadata.user_id %} (ID: {{ metadata.user_id }}){% endif %}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<!-- Change Details -->
|
||||
{% if audit_log.field_name %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-xl font-semibold mb-4">{{ _('Change Details') }}</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">{{ _('Field Change Details') }}</h2>
|
||||
<div class="space-y-4">
|
||||
{% if audit_log.old_value %}
|
||||
<div>
|
||||
@@ -99,6 +138,41 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Full Entity State -->
|
||||
{% if audit_log.get_full_old_state() or audit_log.get_full_new_state() %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow lg:col-span-2">
|
||||
<h2 class="text-xl font-semibold mb-4">{{ _('Full Entity State') }}</h2>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{% if audit_log.get_full_old_state() %}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">{{ _('Before Change') }}</h3>
|
||||
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 max-h-96 overflow-y-auto">
|
||||
{% set old_state = audit_log.get_full_old_state() %}
|
||||
{% if old_state is mapping or old_state is iterable and old_state is not string %}
|
||||
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ old_state|tojson(indent=2) }}</pre>
|
||||
{% else %}
|
||||
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ old_state }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if audit_log.get_full_new_state() %}
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">{{ _('After Change') }}</h3>
|
||||
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 max-h-96 overflow-y-auto">
|
||||
{% set new_state = audit_log.get_full_new_state() %}
|
||||
{% if new_state is mapping or new_state is iterable and new_state is not string %}
|
||||
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ new_state|tojson(indent=2) }}</pre>
|
||||
{% else %}
|
||||
<pre class="text-xs text-gray-900 dark:text-gray-100 whitespace-pre-wrap break-words">{{ new_state }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Request Information -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow lg:col-span-2">
|
||||
<h2 class="text-xl font-semibold mb-4">{{ _('Request Information') }}</h2>
|
||||
|
||||
@@ -119,6 +119,7 @@
|
||||
|
||||
<form id="bulkDeleteForm" method="POST" action="{{ url_for('timer.bulk_delete_time_entries') }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="reason" id="bulkDeleteReason">
|
||||
{% for key, value in filters.items() %}
|
||||
{% if value %}
|
||||
<input type="hidden" name="{{ key }}" value="{{ value }}">
|
||||
@@ -131,6 +132,30 @@
|
||||
{% include 'timer/_time_entries_list.html' %}
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Dialog -->
|
||||
<div id="bulkDeleteDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-semibold mb-4 text-rose-600">{{ _('Delete Selected Time Entries') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4" id="bulkDeleteMessage"></p>
|
||||
<div class="mb-4">
|
||||
<label for="bulkDeleteReasonInput" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{{ _('Reason for Deletion') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional but recommended') }})</span>
|
||||
</label>
|
||||
<textarea id="bulkDeleteReasonInput" class="form-input w-full" rows="3" placeholder="{{ _('e.g., Duplicate entry, incorrect time, etc.') }}"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="closeBulkDeleteDialog()">
|
||||
{{ _('Cancel') }}
|
||||
</button>
|
||||
<button type="button" class="px-4 py-2 bg-rose-600 text-white rounded-lg hover:bg-rose-700 transition-colors" onclick="confirmBulkDelete()">
|
||||
<i class="fas fa-trash mr-2"></i>{{ _('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Mark Paid Dialog -->
|
||||
<div id="bulkPaidDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
@@ -295,21 +320,28 @@ function showBulkDeleteConfirm() {
|
||||
}
|
||||
const count = checkboxes.length;
|
||||
const msg = `{{ _("Are you sure you want to delete") }} ${count} {{ _("time entry/entries") }}? {{ _("This action cannot be undone.") }}`;
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg, { title: '{{ _("Delete Time Entries") }}', confirmText: '{{ _("Delete") }}', variant: 'danger' }).then(function(ok) {
|
||||
if (ok) submitBulkDelete();
|
||||
});
|
||||
} else if (confirm(msg)) {
|
||||
submitBulkDelete();
|
||||
}
|
||||
|
||||
// Show custom dialog with reason field
|
||||
document.getElementById('bulkDeleteMessage').textContent = msg;
|
||||
document.getElementById('bulkDeleteReasonInput').value = '';
|
||||
document.getElementById('bulkDeleteDialog').classList.remove('hidden');
|
||||
|
||||
// Close the menu
|
||||
document.getElementById('bulkActionsMenu').classList.add('hidden');
|
||||
return false;
|
||||
}
|
||||
|
||||
function submitBulkDelete() {
|
||||
function closeBulkDeleteDialog() {
|
||||
document.getElementById('bulkDeleteDialog').classList.add('hidden');
|
||||
}
|
||||
|
||||
function confirmBulkDelete() {
|
||||
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
|
||||
const form = document.getElementById('bulkDeleteForm');
|
||||
const reasonInput = document.getElementById('bulkDeleteReasonInput');
|
||||
|
||||
// Set reason in form
|
||||
document.getElementById('bulkDeleteReason').value = reasonInput.value.trim();
|
||||
|
||||
// Clear existing entry IDs
|
||||
form.querySelectorAll('input[name="entry_ids[]"]').forEach(input => input.remove());
|
||||
@@ -323,6 +355,8 @@ function submitBulkDelete() {
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
// Close dialog and submit
|
||||
closeBulkDeleteDialog();
|
||||
form.submit();
|
||||
}
|
||||
|
||||
|
||||
@@ -152,6 +152,64 @@ def serialize_value(value):
|
||||
return str(value)
|
||||
|
||||
|
||||
def capture_timeentry_metadata(entry):
|
||||
"""Capture TimeEntry metadata for audit logging
|
||||
|
||||
Args:
|
||||
entry: TimeEntry instance
|
||||
|
||||
Returns:
|
||||
dict with client_id, project_id, created_at, and related entity names
|
||||
"""
|
||||
metadata = {
|
||||
"client_id": entry.client_id,
|
||||
"client_name": entry.client.name if entry.client else None,
|
||||
"project_id": entry.project_id,
|
||||
"project_name": entry.project.name if entry.project else None,
|
||||
"task_id": entry.task_id,
|
||||
"task_name": entry.task.name if entry.task else None,
|
||||
"created_at": entry.created_at.isoformat() if hasattr(entry, "created_at") and entry.created_at else None,
|
||||
"user_id": entry.user_id,
|
||||
"user_name": entry.user.username if entry.user else None,
|
||||
}
|
||||
return metadata
|
||||
|
||||
|
||||
def capture_timeentry_state(entry):
|
||||
"""Capture full TimeEntry state for audit logging
|
||||
|
||||
Args:
|
||||
entry: TimeEntry instance
|
||||
|
||||
Returns:
|
||||
dict with all TimeEntry fields and related entity information
|
||||
"""
|
||||
state = {
|
||||
"id": entry.id if hasattr(entry, "id") else None,
|
||||
"user_id": entry.user_id,
|
||||
"project_id": entry.project_id,
|
||||
"client_id": entry.client_id,
|
||||
"task_id": entry.task_id,
|
||||
"start_time": entry.start_time.isoformat() if entry.start_time else None,
|
||||
"end_time": entry.end_time.isoformat() if entry.end_time else None,
|
||||
"duration_seconds": entry.duration_seconds,
|
||||
"notes": entry.notes,
|
||||
"tags": entry.tags,
|
||||
"source": entry.source,
|
||||
"billable": entry.billable,
|
||||
"paid": entry.paid,
|
||||
"invoice_number": entry.invoice_number,
|
||||
"created_at": entry.created_at.isoformat() if hasattr(entry, "created_at") and entry.created_at else None,
|
||||
"updated_at": entry.updated_at.isoformat() if hasattr(entry, "updated_at") and entry.updated_at else None,
|
||||
# Related entity names for context
|
||||
"project_name": entry.project.name if entry.project else None,
|
||||
"client_name": entry.client.name if entry.client else None,
|
||||
"task_name": entry.task.name if entry.task else None,
|
||||
"user_name": entry.user.username if entry.user else None,
|
||||
}
|
||||
return state
|
||||
|
||||
|
||||
# Call count for table-exists check (force recheck every 100) and warning/debug logs
|
||||
_audit_call_count = 0
|
||||
|
||||
@@ -190,6 +248,16 @@ def receive_before_flush(session, flush_context, instances=None):
|
||||
entity_name = get_entity_name(instance)
|
||||
|
||||
try:
|
||||
# For TimeEntry, capture full old state before changes
|
||||
full_old_state = None
|
||||
entity_metadata = None
|
||||
if entity_type == "TimeEntry":
|
||||
try:
|
||||
full_old_state = capture_timeentry_state(instance)
|
||||
entity_metadata = capture_timeentry_metadata(instance)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not capture TimeEntry state for {entity_id}: {e}")
|
||||
|
||||
instance_state = inspect(instance)
|
||||
changed_fields = []
|
||||
for attr_name in instance_state.mapper.column_attrs.keys():
|
||||
@@ -214,6 +282,8 @@ def receive_before_flush(session, flush_context, instances=None):
|
||||
new_value=serialize_value(change["new"]),
|
||||
entity_name=entity_name,
|
||||
change_description=f"Updated {entity_type.lower()} '{entity_name}': {change['field']}",
|
||||
entity_metadata=entity_metadata,
|
||||
full_old_state=full_old_state,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_path=request_path,
|
||||
@@ -226,6 +296,8 @@ def receive_before_flush(session, flush_context, instances=None):
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
change_description=f"Updated {entity_type.lower()} '{entity_name}'",
|
||||
entity_metadata=entity_metadata,
|
||||
full_old_state=full_old_state,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_path=request_path,
|
||||
@@ -252,6 +324,16 @@ def receive_before_flush(session, flush_context, instances=None):
|
||||
entity_id = instance.id if hasattr(instance, "id") else None
|
||||
entity_name = get_entity_name(instance)
|
||||
try:
|
||||
# For TimeEntry, capture full state and metadata before deletion
|
||||
full_old_state = None
|
||||
entity_metadata = None
|
||||
if entity_type == "TimeEntry":
|
||||
try:
|
||||
full_old_state = capture_timeentry_state(instance)
|
||||
entity_metadata = capture_timeentry_metadata(instance)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not capture TimeEntry state for deletion of {entity_id}: {e}")
|
||||
|
||||
AuditLog = get_audit_log_model()
|
||||
AuditLog.log_change(
|
||||
user_id=user_id,
|
||||
@@ -260,6 +342,8 @@ def receive_before_flush(session, flush_context, instances=None):
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
change_description=f"Deleted {entity_type.lower()} '{entity_name}'",
|
||||
entity_metadata=entity_metadata,
|
||||
full_old_state=full_old_state,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_path=request_path,
|
||||
@@ -298,6 +382,17 @@ def receive_after_flush(session, flush_context):
|
||||
if entity_id is None:
|
||||
continue
|
||||
entity_name = get_entity_name(instance)
|
||||
|
||||
# For TimeEntry, capture full state and metadata
|
||||
full_new_state = None
|
||||
entity_metadata = None
|
||||
if entity_type == "TimeEntry":
|
||||
try:
|
||||
full_new_state = capture_timeentry_state(instance)
|
||||
entity_metadata = capture_timeentry_metadata(instance)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not capture TimeEntry state for creation of {entity_id}: {e}")
|
||||
|
||||
AuditLog = get_audit_log_model()
|
||||
AuditLog.log_change(
|
||||
user_id=user_id,
|
||||
@@ -306,6 +401,8 @@ def receive_after_flush(session, flush_context):
|
||||
entity_id=entity_id,
|
||||
entity_name=entity_name,
|
||||
change_description=f"Created {entity_type.lower()} '{entity_name}'",
|
||||
entity_metadata=entity_metadata,
|
||||
full_new_state=full_new_state,
|
||||
ip_address=ip_address,
|
||||
user_agent=user_agent,
|
||||
request_path=request_path,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Enhance audit_logs table for TimeEntry comprehensive tracking
|
||||
|
||||
Revision ID: 114_enhance_audit_logs_timeentry
|
||||
Revises: 113_add_invoice_buyer_reference
|
||||
Create Date: 2025-01-30
|
||||
|
||||
Adds columns for reason, entity_metadata, full_old_state, and full_new_state
|
||||
to support comprehensive TimeEntry audit logging.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '114_enhance_audit_logs_timeentry'
|
||||
down_revision = '113_add_invoice_buyer_reference'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add new columns to audit_logs table for enhanced TimeEntry tracking"""
|
||||
from sqlalchemy import inspect
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
if 'audit_logs' not in existing_tables:
|
||||
print("⊘ Table audit_logs does not exist, skipping enhancement")
|
||||
return
|
||||
|
||||
# Check which columns already exist
|
||||
existing_columns = [col['name'] for col in inspector.get_columns('audit_logs')]
|
||||
|
||||
# Add reason column
|
||||
if 'reason' not in existing_columns:
|
||||
try:
|
||||
op.add_column('audit_logs', sa.Column('reason', sa.Text(), nullable=True))
|
||||
print("✓ Added reason column to audit_logs")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not add reason column: {e}")
|
||||
|
||||
# Add entity_metadata column (JSON for flexibility)
|
||||
if 'entity_metadata' not in existing_columns:
|
||||
try:
|
||||
# Use JSON for PostgreSQL, Text for SQLite
|
||||
conn = op.get_bind()
|
||||
is_postgres = conn.dialect.name == 'postgresql'
|
||||
|
||||
if is_postgres:
|
||||
op.add_column('audit_logs', sa.Column('entity_metadata', postgresql.JSON(astext_type=sa.Text()), nullable=True))
|
||||
else:
|
||||
op.add_column('audit_logs', sa.Column('entity_metadata', sa.Text(), nullable=True))
|
||||
print("✓ Added entity_metadata column to audit_logs")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not add entity_metadata column: {e}")
|
||||
|
||||
# Add full_old_state column
|
||||
if 'full_old_state' not in existing_columns:
|
||||
try:
|
||||
op.add_column('audit_logs', sa.Column('full_old_state', sa.Text(), nullable=True))
|
||||
print("✓ Added full_old_state column to audit_logs")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not add full_old_state column: {e}")
|
||||
|
||||
# Add full_new_state column
|
||||
if 'full_new_state' not in existing_columns:
|
||||
try:
|
||||
op.add_column('audit_logs', sa.Column('full_new_state', sa.Text(), nullable=True))
|
||||
print("✓ Added full_new_state column to audit_logs")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not add full_new_state column: {e}")
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove enhanced columns from audit_logs table"""
|
||||
from sqlalchemy import inspect
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
if 'audit_logs' not in existing_tables:
|
||||
print("⊘ Table audit_logs does not exist, skipping")
|
||||
return
|
||||
|
||||
existing_columns = [col['name'] for col in inspector.get_columns('audit_logs')]
|
||||
|
||||
# Remove columns in reverse order
|
||||
columns_to_remove = ['full_new_state', 'full_old_state', 'entity_metadata', 'reason']
|
||||
|
||||
for col_name in columns_to_remove:
|
||||
if col_name in existing_columns:
|
||||
try:
|
||||
op.drop_column('audit_logs', col_name)
|
||||
print(f"✓ Removed {col_name} column from audit_logs")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not remove {col_name} column: {e}")
|
||||
@@ -332,6 +332,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason for Change -->
|
||||
<div class="mb-6">
|
||||
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-comment-alt mr-1"></i>{{ _('Reason for Change') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional but recommended') }})</span>
|
||||
</label>
|
||||
<textarea class="form-input" id="reason" name="reason" rows="3" placeholder="{{ _('e.g., Corrected duration, updated project assignment, etc.') }}"></textarea>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Explain why you are making these changes') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row justify-between gap-3 mt-8 pt-6 border-t border-border-light dark:border-border-dark">
|
||||
<div class="flex gap-2">
|
||||
@@ -441,6 +450,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reason for Change -->
|
||||
<div class="mb-6">
|
||||
<label for="reason" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-comment-alt mr-1"></i>{{ _('Reason for Change') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional but recommended') }})</span>
|
||||
</label>
|
||||
<textarea class="form-input" id="reason" name="reason" rows="3" placeholder="{{ _('e.g., Corrected duration, updated project assignment, etc.') }}"></textarea>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Explain why you are making these changes') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row justify-between gap-3 mt-8 pt-6 border-t border-border-light dark:border-border-dark">
|
||||
<div class="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user