Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/routes/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2148,13 +2148,15 @@ def pdf_layout_preview():
from datetime import date

invoice = SimpleNamespace(
id=None,
invoice_number="0000",
issue_date=date.today(),
due_date=date.today(),
status="draft",
client_name="Sample Client",
client_email="",
client_address="",
client=SimpleNamespace(name="Sample Client", email="", address=""),
project=SimpleNamespace(name="Sample Project", description=""),
items=[],
extra_goods=[],
Expand Down Expand Up @@ -2209,16 +2211,17 @@ def pdf_layout_preview():
pass

# Copy relationship attributes (project, client)
_invoice_id = getattr(invoice, "id", None)
try:
invoice_wrapper.project = invoice.project
except (AttributeError, RuntimeError) as e:
current_app.logger.debug(f"Could not access invoice.project for invoice {invoice.id}: {e}")
current_app.logger.debug(f"Could not access invoice.project for invoice {_invoice_id}: {e}")
invoice_wrapper.project = SimpleNamespace(name="Sample Project", description="")

try:
invoice_wrapper.client = invoice.client
invoice_wrapper.client = getattr(invoice, "client", None)
except (AttributeError, RuntimeError) as e:
current_app.logger.debug(f"Could not access invoice.client for invoice {invoice.id}: {e}")
current_app.logger.debug(f"Could not access invoice.client for invoice {_invoice_id}: {e}")
invoice_wrapper.client = None

# Convert items from Query to list
Expand Down
2 changes: 1 addition & 1 deletion app/routes/api_v1.py
Original file line number Diff line number Diff line change
Expand Up @@ -1536,7 +1536,7 @@ def update_invoice(invoice_id):
current_app.logger.warning(f"Invalid tax_rate value in invoice update: {data.get('tax_rate')} - {e}")
if "amount_paid" in data:
try:
from decimal import Decimal
from decimal import Decimal, InvalidOperation

update_kwargs["amount_paid"] = Decimal(str(data["amount_paid"]))
except (ValueError, TypeError, InvalidOperation) as e:
Expand Down
2 changes: 1 addition & 1 deletion app/routes/custom_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Routes for custom report builder.
"""

from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
Expand Down
2 changes: 2 additions & 0 deletions app/routes/integrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ class MinimalConnector:
# Per-user integration
integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first()

user_integration = None if is_global else integration

# Handle POST (OAuth credential updates - admin only for global integrations)
if request.method == "POST":
if is_global and not current_user.is_admin:
Expand Down
2 changes: 1 addition & 1 deletion app/routes/invoice_approvals.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Routes for invoice approval workflow.
"""

from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app.models import Invoice, InvoiceApproval, User
Expand Down
4 changes: 2 additions & 2 deletions app/routes/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,8 +1073,8 @@ def export_invoice_ubl(invoice_id):

svc = PeppolService()
sender = svc._get_sender_party()
recipient_party, _, _ = svc._get_recipient_party(invoice)
ubl_xml, _ = build_peppol_ubl_invoice_xml(invoice=invoice, supplier=sender, customer=recipient_party)
recipient_party, _ign, _ign = svc._get_recipient_party(invoice)
ubl_xml, _ign = build_peppol_ubl_invoice_xml(invoice=invoice, supplier=sender, customer=recipient_party)
fn = f"invoice_{invoice.invoice_number}.xml"
return Response(ubl_xml, mimetype="application/xml", headers={"Content-Disposition": f"attachment; filename={fn}"})
except ValueError as e:
Expand Down
2 changes: 1 addition & 1 deletion app/routes/reports.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, jsonify, current_app
from flask_login import login_required, current_user
from flask_babel import _
from app import db, log_event, track_event
Expand Down
2 changes: 1 addition & 1 deletion app/routes/scheduled_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Routes for scheduled reports management.
"""

from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app.models import SavedReportView, ReportEmailSchedule
Expand Down
2 changes: 1 addition & 1 deletion app/routes/tasks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import re

from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response, Response
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response, Response, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
import app as app_module
Expand Down
3 changes: 0 additions & 3 deletions app/utils/audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,9 +408,6 @@ def receive_after_flush(session, flush_context):
request_path=request_path,
)

if pending:
session.flush()

except Exception as e:
logger.error(f"Error in audit logging (after_flush): {e}", exc_info=True)

Expand Down
3 changes: 3 additions & 0 deletions app/utils/data_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import json
import csv
import logging
import requests
from datetime import datetime, timedelta
from io import StringIO
Expand All @@ -12,6 +13,8 @@
from app.models import User, Project, TimeEntry, Task, Client, Expense, ExpenseCategory, Contact
from app.utils.db import safe_commit

logger = logging.getLogger(__name__)


class ImportError(Exception):
"""Custom exception for import errors"""
Expand Down
3 changes: 3 additions & 0 deletions app/utils/excel_export.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
"""Excel export utilities for reports and data export"""

import io
import logging
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
from app.utils.timezone import convert_app_datetime_to_user

logger = logging.getLogger(__name__)


def create_time_entries_excel(entries, filename_prefix="timetracker_export"):
"""Create Excel file from time entries
Expand Down
29 changes: 29 additions & 0 deletions migrations/versions/116_merge_three_heads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Merge three migration heads into one

Revision ID: 116_merge_three_heads
Revises: 090_add_push_subscriptions, 100_gantt_colors_modules, 115_add_exclude_weekends
Create Date: 2026-01-25

Merge revision to resolve multiple heads:
- 090_add_push_subscriptions
- 100_gantt_colors_modules
- 115_add_exclude_weekends
"""
from alembic import op


# revision identifiers, used by Alembic.
revision = '116_merge_three_heads'
down_revision = ('090_add_push_subscriptions', '100_gantt_colors_modules', '115_add_exclude_weekends')
branch_labels = None
depends_on = None


def upgrade():
"""No schema changes - merge only."""
pass


def downgrade():
"""No schema changes - merge only."""
pass
24 changes: 12 additions & 12 deletions tests/test_admin_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ class TestAdminUserList:
def test_list_users_as_admin(self, client, admin_user):
"""Test that admin can view user list."""
# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

response = client.get(url_for("admin.list_users"))
assert response.status_code == 200
Expand All @@ -32,7 +32,7 @@ def test_list_users_as_admin(self, client, admin_user):
def test_list_users_as_regular_user_denied(self, client, user):
"""Test that regular users cannot access user list."""
# Login as regular user using the login endpoint
client.post("/login", data={"username": user.username}, follow_redirects=True)
client.post("/login", data={"username": user.username, "password": "password123"}, follow_redirects=True)

response = client.get(url_for("admin.list_users"))
# Should redirect or show error
Expand Down Expand Up @@ -538,7 +538,7 @@ def test_delete_user_cascades_to_project_costs(self, client, admin_user, user, t
def test_user_list_shows_delete_button_for_other_users(self, client, admin_user, user):
"""Test that the user list shows delete button for other users."""
# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

response = client.get(url_for("admin.list_users"))
assert response.status_code == 200
Expand All @@ -550,7 +550,7 @@ def test_user_list_shows_delete_button_for_other_users(self, client, admin_user,
def test_user_list_hides_delete_button_for_current_user(self, client, admin_user):
"""Test that the user list doesn't show delete button for current user."""
# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

response = client.get(url_for("admin.list_users"))
assert response.status_code == 200
Expand Down Expand Up @@ -581,7 +581,7 @@ def test_admin_can_delete_user_without_data(self, client, admin_user, app):
user_id = clean_user.id

# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

# Delete the user
response = client.post(url_for("admin.delete_user", user_id=user_id), follow_redirects=True)
Expand Down Expand Up @@ -612,7 +612,7 @@ def test_cannot_delete_user_with_time_entries(self, client, admin_user, user, te
user_id = user.id

# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

# Try to delete
response = client.post(url_for("admin.delete_user", user_id=user_id), follow_redirects=True)
Expand All @@ -634,7 +634,7 @@ def test_cannot_delete_user_with_time_entries(self, client, admin_user, user, te
def test_cannot_delete_last_admin(self, client, admin_user, app):
"""SMOKE: System prevents deletion of the last administrator."""
# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

# Try to delete the only admin
response = client.post(url_for("admin.delete_user", user_id=admin_user.id), follow_redirects=True)
Expand All @@ -651,7 +651,7 @@ def test_cannot_delete_last_admin(self, client, admin_user, app):
def test_user_list_accessible_to_admin(self, client, admin_user):
"""SMOKE: Admin can access user list page."""
# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

response = client.get(url_for("admin.list_users"))

Expand Down Expand Up @@ -686,7 +686,7 @@ def test_regular_user_cannot_access_user_deletion(self, client, user, app):
def test_delete_button_appears_in_ui(self, client, admin_user, user):
"""SMOKE: Delete button appears in user list UI."""
# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

response = client.get(url_for("admin.list_users"))

Expand All @@ -709,7 +709,7 @@ def test_complete_user_deletion_workflow(self, client, admin_user, app):
user_id = new_user.id

# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

# Step 2: View user list (should show user)
response = client.get(url_for("admin.list_users"))
Expand Down Expand Up @@ -741,7 +741,7 @@ def test_admin_can_reset_user_password(self, client, admin_user, user, app):
user_id = user.id

# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

# Reset password
response = client.post(
Expand Down Expand Up @@ -770,7 +770,7 @@ def test_admin_can_reset_user_password(self, client, admin_user, user, app):
def test_password_reset_form_accessible(self, client, admin_user, user):
"""SMOKE: Password reset form is accessible to admin."""
# Login as admin using the login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

# Access edit form
response = client.get(url_for("admin.edit_user", user_id=user.id))
Expand Down
23 changes: 15 additions & 8 deletions tests/test_audit_trail_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ def test_audit_log_creation_smoke(self, app, test_user, test_project):
change_description="Smoke test audit log",
)

# Verify log was created
logs = AuditLog.query.filter_by(entity_type="Project", entity_id=test_project.id).all()
# Verify log was created (filter by user so we get the one we created, not fixture-created logs)
logs = AuditLog.query.filter_by(
entity_type="Project", entity_id=test_project.id, user_id=test_user.id, action="created"
).all()

assert len(logs) > 0
assert len(logs) >= 1
assert logs[0].action == "created"
assert logs[0].user_id == test_user.id

Expand Down Expand Up @@ -69,12 +71,14 @@ def test_audit_log_entity_history_smoke(self, app, test_user, test_project):
entity_name=test_project.name,
)

# Retrieve entity history
# Retrieve entity history (may include fixture-created log; we expect at least our 3 updates)
history = AuditLog.get_for_entity("Project", test_project.id, limit=10)

assert len(history) == 3
assert len(history) >= 3
assert all(log.entity_type == "Project" for log in history)
assert all(log.entity_id == test_project.id for log in history)
updated_logs = [log for log in history if log.action == "updated"]
assert len(updated_logs) == 3

def test_audit_log_user_activity_smoke(self, app, test_user, test_project):
"""Smoke test: Retrieve user activity history"""
Expand Down Expand Up @@ -127,10 +131,13 @@ def test_audit_log_filtering_smoke(self, app, test_user, test_project):
entity_name=test_project.name,
)

# Filter by action
created_logs = AuditLog.get_recent(action="created", limit=10)
assert len(created_logs) == 1
# Filter by action and user so we only count the test's created log, not fixture-created logs
created_logs = AuditLog.get_recent(action="created", user_id=test_user.id, limit=10)
assert len(created_logs) >= 1
assert created_logs[0].action == "created"
# Our created log is for this project
our_created = [log for log in created_logs if log.entity_type == "Project" and log.entity_id == test_project.id]
assert len(our_created) == 1

# Filter by entity type
project_logs = AuditLog.get_recent(entity_type="Project", limit=10)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -1489,7 +1489,7 @@ def test_invoice_view_has_delete_button(app, client, user, project):
from app.models import Client

# Authenticate using login endpoint
client.post("/login", data={"username": user.username}, follow_redirects=True)
client.post("/login", data={"username": user.username, "password": "password123"}, follow_redirects=True)

# Create client and invoice
cl = ClientFactory(name="Delete Button Test Client", email="button@test.com")
Expand Down Expand Up @@ -1534,7 +1534,7 @@ def test_invoice_list_has_delete_buttons(app, client, admin_user, project):
project_id = project.id

# Authenticate as admin using login endpoint
client.post("/login", data={"username": admin_user.username}, follow_redirects=True)
client.post("/login", data={"username": admin_user.username, "password": "password123"}, follow_redirects=True)

# Create client and invoices
cl = Client(name="List Delete Test Client", email="listdelete@test.com")
Expand Down Expand Up @@ -1591,7 +1591,7 @@ def test_delete_invoice_with_complex_data_smoke(app, client, user, project):
from app.models.payments import Payment

# Authenticate using login endpoint
client.post("/login", data={"username": user.username}, follow_redirects=True)
client.post("/login", data={"username": user.username, "password": "password123"}, follow_redirects=True)

# Create client and invoice
cl = Client(name="Complex Delete Test", email="complex@test.com")
Expand Down
Loading
Loading