From 90dde470da961ee74145686f1f8491b24e070642 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Fri, 28 Nov 2025 20:05:37 +0100 Subject: [PATCH 01/17] style: standardize code formatting and normalize line endings - Normalize line endings from CRLF to LF across all files to match .editorconfig - Standardize quote style from single quotes to double quotes - Normalize whitespace and formatting throughout codebase - Apply consistent code style across 372 files including: * Application code (models, routes, services, utils) * Test files * Configuration files * CI/CD workflows This ensures consistency with the project's .editorconfig settings and improves code maintainability. --- .github/workflows/cd-release.yml | 4 +- .github/workflows/ci-comprehensive.yml | 10 +- .github/workflows/ci.yml | 10 +- app/__init__.py | 338 +- app/config.py | 206 +- app/config/__init__.py | 17 +- app/config/analytics_defaults.py | 41 +- app/constants.py | 36 +- app/integrations/__init__.py | 3 +- app/integrations/base.py | 63 +- app/integrations/github.py | 174 +- app/integrations/jira.py | 195 +- app/integrations/registry.py | 7 +- app/integrations/slack.py | 222 +- app/models/activity.py | 161 +- app/models/api_token.py | 103 +- app/models/audit_log.py | 177 +- app/models/budget_alert.py | 137 +- app/models/calendar_event.py | 235 +- app/models/calendar_integration.py | 62 +- app/models/client.py | 210 +- app/models/client_note.py | 102 +- app/models/client_prepaid_consumption.py | 25 +- app/models/comment.py | 149 +- app/models/contact.py | 123 +- app/models/contact_communication.py | 101 +- app/models/currency.py | 14 +- app/models/deal.py | 191 +- app/models/deal_activity.py | 71 +- app/models/expense.py | 382 +- app/models/expense_category.py | 140 +- app/models/extra_good.py | 141 +- app/models/focus_session.py | 44 +- app/models/import_export.py | 171 +- app/models/integration.py | 36 +- app/models/invoice.py | 330 +- app/models/invoice_approval.py | 88 +- app/models/invoice_email.py | 89 +- app/models/invoice_pdf_template.py | 86 +- app/models/invoice_template.py | 4 +- app/models/kanban_column.py | 144 +- app/models/lead.py | 169 +- app/models/lead_activity.py | 71 +- app/models/mileage.py | 250 +- app/models/payment_gateway.py | 88 +- app/models/payments.py | 62 +- app/models/per_diem.py | 414 +-- app/models/permission.py | 95 +- app/models/project.py | 248 +- app/models/project_cost.py | 147 +- app/models/project_stock_allocation.py | 62 +- app/models/project_template.py | 60 +- app/models/purchase_order.py | 199 +- app/models/quote.py | 444 +-- app/models/quote_attachment.py | 107 +- app/models/quote_template.py | 130 +- app/models/quote_version.py | 123 +- app/models/rate_override.py | 24 +- app/models/recurring_block.py | 50 +- app/models/recurring_invoice.py | 212 +- app/models/reporting.py | 14 +- app/models/saved_filter.py | 28 +- app/models/settings.py | 393 +- app/models/stock_item.py | 165 +- app/models/stock_movement.py | 125 +- app/models/stock_reservation.py | 175 +- app/models/supplier.py | 80 +- app/models/supplier_stock_item.py | 77 +- app/models/task.py | 290 +- app/models/task_activity.py | 15 +- app/models/tax_rule.py | 8 +- app/models/time_entry.py | 173 +- app/models/time_entry_template.py | 75 +- app/models/user.py | 221 +- app/models/user_favorite_project.py | 32 +- app/models/warehouse.py | 64 +- app/models/warehouse_stock.py | 68 +- app/models/webhook.py | 254 +- app/models/weekly_time_goal.py | 154 +- app/repositories/__init__.py | 19 +- app/repositories/base_repository.py | 71 +- app/repositories/client_repository.py | 15 +- app/repositories/comment_repository.py | 70 +- app/repositories/expense_repository.py | 65 +- app/repositories/invoice_repository.py | 104 +- app/repositories/payment_repository.py | 68 +- app/repositories/project_repository.py | 59 +- app/repositories/task_repository.py | 59 +- app/repositories/time_entry_repository.py | 131 +- app/repositories/user_repository.py | 16 +- app/routes/admin.py | 2084 ++++++----- app/routes/analytics.py | 1501 ++++---- app/routes/api.py | 1694 ++++----- app/routes/api/__init__.py | 9 +- app/routes/api/v1/__init__.py | 5 +- app/routes/api_docs.py | 296 +- app/routes/api_v1.py | 3215 +++++++++-------- app/routes/audit_logs.py | 223 +- app/routes/auth.py | 597 +-- app/routes/budget_alerts.py | 483 ++- app/routes/calendar.py | 455 ++- app/routes/client_notes.py | 296 +- app/routes/client_portal.py | 433 +-- app/routes/clients.py | 743 ++-- app/routes/comments.py | 261 +- app/routes/contacts.py | 195 +- app/routes/custom_reports.py | 227 +- app/routes/deals.py | 347 +- app/routes/expense_categories.py | 257 +- app/routes/expenses.py | 1232 +++---- app/routes/gantt.py | 138 +- app/routes/import_export.py | 587 ++- app/routes/integrations.py | 220 +- app/routes/inventory.py | 2144 ++++++----- app/routes/invoice_approvals.py | 123 +- app/routes/invoices.py | 1129 +++--- app/routes/invoices_refactored.py | 265 +- app/routes/kanban.py | 335 +- app/routes/kiosk.py | 618 ++-- app/routes/leads.py | 299 +- app/routes/main.py | 286 +- app/routes/mileage.py | 604 ++-- app/routes/offers.py | 485 ++- app/routes/payment_gateways.py | 174 +- app/routes/payments.py | 737 ++-- app/routes/per_diem.py | 803 ++-- app/routes/permissions.py | 277 +- app/routes/project_templates.py | 249 +- app/routes/projects.py | 1650 +++++---- app/routes/projects_refactored_example.py | 190 +- app/routes/push_notifications.py | 49 +- app/routes/quotes.py | 1611 +++++---- app/routes/recurring_invoices.py | 274 +- app/routes/reports.py | 859 ++--- app/routes/saved_filters.py | 276 +- app/routes/scheduled_reports.py | 61 +- app/routes/settings.py | 31 +- app/routes/setup.py | 31 +- app/routes/tasks.py | 1200 +++--- app/routes/time_entry_templates.py | 351 +- app/routes/timer.py | 1441 ++++---- app/routes/timer_refactored.py | 262 +- app/routes/user.py | 277 +- app/routes/webhooks.py | 223 +- app/routes/weekly_goals.py | 370 +- app/schemas/__init__.py | 55 +- app/schemas/client_schema.py | 16 +- app/schemas/comment_schema.py | 14 +- app/schemas/expense_schema.py | 14 +- app/schemas/invoice_schema.py | 9 +- app/schemas/payment_schema.py | 24 +- app/schemas/project_schema.py | 10 +- app/schemas/task_schema.py | 16 +- app/schemas/time_entry_schema.py | 24 +- app/schemas/user_schema.py | 8 +- app/services/__init__.py | 37 +- app/services/analytics_service.py | 109 +- app/services/api_token_service.py | 278 +- app/services/backup_service.py | 161 +- app/services/base_crud_service.py | 180 +- app/services/calendar_integration_service.py | 218 +- app/services/client_service.py | 78 +- app/services/comment_service.py | 178 +- app/services/email_service.py | 78 +- app/services/expense_service.py | 66 +- app/services/export_service.py | 215 +- app/services/health_service.py | 61 +- app/services/import_service.py | 147 +- app/services/integration_service.py | 212 +- app/services/inventory_report_service.py | 251 +- app/services/invoice_approval_service.py | 296 +- app/services/invoice_service.py | 232 +- app/services/notification_service.py | 33 +- app/services/payment_gateway_service.py | 229 +- app/services/payment_service.py | 75 +- app/services/permission_service.py | 141 +- app/services/project_service.py | 290 +- app/services/project_template_service.py | 299 +- app/services/reporting_service.py | 278 +- app/services/scheduled_report_service.py | 281 +- app/services/task_service.py | 203 +- app/services/time_tracking_service.py | 261 +- app/services/user_service.py | 141 +- app/utils/api_auth.py | 108 +- app/utils/api_responses.py | 171 +- app/utils/audit.py | 169 +- app/utils/backup.py | 190 +- app/utils/budget_forecasting.py | 484 ++- app/utils/cache.py | 40 +- app/utils/cache_redis.py | 64 +- app/utils/cli.py | 77 +- app/utils/config_manager.py | 48 +- app/utils/context_processors.py | 93 +- app/utils/data_export.py | 565 +-- app/utils/data_import.py | 370 +- app/utils/datetime_utils.py | 154 +- app/utils/db.py | 6 +- app/utils/email.py | 599 ++- app/utils/env_validation.py | 143 +- app/utils/error_handlers.py | 178 +- app/utils/event_bus.py | 29 +- app/utils/excel_export.py | 427 ++- app/utils/file_upload.py | 64 +- app/utils/i18n.py | 25 +- app/utils/i18n_helpers.py | 349 +- app/utils/installation.py | 83 +- app/utils/logger.py | 97 +- app/utils/ocr.py | 250 +- app/utils/overtime.py | 251 +- app/utils/pagination.py | 81 +- app/utils/pdf_generator.py | 451 ++- app/utils/pdf_generator_fallback.py | 614 ++-- app/utils/performance.py | 35 +- app/utils/permissions.py | 82 +- app/utils/permissions_seed.py | 405 ++- app/utils/posthog_features.py | 113 +- app/utils/posthog_funnels.py | 95 +- app/utils/posthog_monitoring.py | 263 +- app/utils/posthog_segmentation.py | 184 +- app/utils/prepaid_hours.py | 48 +- app/utils/query_logging.py | 59 +- app/utils/query_optimization.py | 87 +- app/utils/rate_limiting.py | 32 +- app/utils/route_helpers.py | 76 +- app/utils/scheduled_tasks.py | 287 +- app/utils/search.py | 134 +- app/utils/stripe_integration.py | 125 +- app/utils/telemetry.py | 127 +- app/utils/template_filters.py | 443 ++- app/utils/time_rounding.py | 87 +- app/utils/timezone.py | 64 +- app/utils/transactions.py | 23 +- app/utils/validation.py | 101 +- app/utils/webhook_dispatcher.py | 154 +- app/utils/webhook_service.py | 256 +- .../testing/TEST_PERFORMANCE_OPTIMIZATIONS.md | 224 ++ logs/app.jsonl | 413 +++ pyproject.toml | 4 +- pytest.ini | 7 +- scripts/run-tests.bat | 4 +- scripts/run-tests.sh | 4 +- tests/conftest.py | 443 ++- tests/factories.py | 13 +- tests/models/test_import_export_models.py | 340 +- tests/smoke_test_email.py | 128 +- tests/smoke_test_prepaid_hours.py | 33 +- tests/smoke_test_project_dashboard.py | 302 +- tests/smoke_test_user_settings.py | 252 +- tests/test_activity_feed.py | 354 +- tests/test_admin_email_routes.py | 213 +- tests/test_admin_settings_logo.py | 257 +- tests/test_admin_users.py | 481 ++- tests/test_analytics.py | 175 +- tests/test_api_audit_activities_v1.py | 27 +- tests/test_api_budget_alerts_v1.py | 45 +- tests/test_api_calendar_v1.py | 43 +- tests/test_api_client_notes_v1.py | 45 +- tests/test_api_comments_v1.py | 41 +- tests/test_api_comprehensive.py | 178 +- tests/test_api_credit_notes_v1.py | 43 +- tests/test_api_expenses_v1.py | 53 +- tests/test_api_favorites_v1.py | 39 +- tests/test_api_invoice_templates_api_v1.py | 27 +- tests/test_api_invoice_templates_v1.py | 43 +- tests/test_api_invoices_v1.py | 75 +- tests/test_api_kanban_v1.py | 35 +- tests/test_api_mileage_v1.py | 53 +- tests/test_api_payments_v1.py | 47 +- tests/test_api_per_diem_v1.py | 57 +- tests/test_api_project_costs_v1.py | 51 +- tests/test_api_recurring_invoices_v1.py | 57 +- tests/test_api_saved_filters_v1.py | 41 +- tests/test_api_tax_currency_v1.py | 51 +- tests/test_api_time_entry_templates_v1.py | 41 +- tests/test_api_v1.py | 461 +-- tests/test_audit_log_model.py | 201 +- tests/test_audit_log_routes.py | 141 +- tests/test_audit_logging.py | 104 +- tests/test_audit_trail_smoke.py | 161 +- tests/test_basic.py | 125 +- tests/test_budget_alert_model.py | 345 +- tests/test_budget_alerts_smoke.py | 289 +- tests/test_budget_forecasting.py | 237 +- tests/test_bulk_task_operations.py | 391 +- tests/test_calendar_event_model.py | 323 +- tests/test_calendar_routes.py | 360 +- tests/test_client_note_model.py | 318 +- tests/test_client_notes_routes.py | 330 +- tests/test_client_portal.py | 270 +- tests/test_client_prepaid_model.py | 24 +- tests/test_comprehensive_tracking.py | 191 +- tests/test_config_priority.py | 135 +- tests/test_currency_display.py | 339 +- tests/test_delete_actions.py | 8 +- tests/test_email.py | 315 +- tests/test_enhanced_ui.py | 201 +- tests/test_error_handling.py | 135 +- tests/test_excel_export.py | 92 +- tests/test_expenses.py | 512 ++- tests/test_extra_good_model.py | 187 +- tests/test_factories_smoke.py | 5 +- tests/test_favorite_projects.py | 286 +- tests/test_i18n.py | 294 +- tests/test_import_export.py | 363 +- tests/test_installation_config.py | 125 +- .../test_inventory_integration.py | 271 +- tests/test_invoice_currency_fix.py | 180 +- tests/test_invoice_currency_smoke.py | 92 +- tests/test_invoice_email.py | 394 +- tests/test_invoice_expenses.py | 123 +- tests/test_invoices.py | 1230 ++++--- tests/test_keyboard_shortcuts.py | 175 +- tests/test_keyboard_shortcuts_input_fix.py | 251 +- tests/test_models/test_expense_category.py | 157 +- tests/test_models/test_inventory_models.py | 388 +- tests/test_models/test_mileage.py | 215 +- tests/test_models/test_per_diem.py | 209 +- tests/test_models/test_webhook.py | 173 +- tests/test_models_comprehensive.py | 315 +- tests/test_models_extended.py | 342 +- tests/test_new_features.py | 26 +- tests/test_oidc_logout.py | 163 +- tests/test_onboarding.py | 68 +- tests/test_overtime.py | 295 +- tests/test_overtime_smoke.py | 223 +- tests/test_payment_model.py | 296 +- tests/test_payment_routes.py | 347 +- tests/test_payment_smoke.py | 377 +- tests/test_pdf_layout.py | 238 +- tests/test_permissions.py | 288 +- tests/test_permissions_routes.py | 220 +- tests/test_prepaid_allocator.py | 40 +- tests/test_profile_avatar.py | 58 +- tests/test_project_archiving.py | 415 +-- tests/test_project_archiving_models.py | 271 +- tests/test_project_costs.py | 441 ++- tests/test_project_dashboard.py | 291 +- tests/test_project_inactive_status.py | 146 +- tests/test_repositories/__init__.py | 1 - .../test_repositories/test_base_repository.py | 51 +- .../test_time_entry_repository.py | 70 +- tests/test_routes.py | 342 +- tests/test_routes/test_inventory_routes.py | 198 +- tests/test_security.py | 248 +- tests/test_service_worker.py | 11 +- tests/test_services/__init__.py | 1 - tests/test_services/test_api_token_service.py | 83 +- tests/test_services/test_comment_service.py | 107 +- tests/test_services/test_export_service.py | 60 +- tests/test_services/test_invoice_service.py | 54 +- tests/test_services/test_payment_service.py | 95 +- tests/test_services/test_project_service.py | 97 +- tests/test_services/test_reporting_service.py | 57 +- tests/test_services/test_task_service.py | 76 +- .../test_time_tracking_service.py | 117 +- tests/test_task_edit_project.py | 42 +- tests/test_tasks_filters_ui.py | 15 +- tests/test_tasks_templates.py | 48 +- tests/test_telemetry.py | 247 +- tests/test_time_entry_duplication.py | 218 +- tests/test_time_entry_freeze.py | 3 +- tests/test_time_entry_resume.py | 193 +- tests/test_time_entry_templates.py | 537 ++- tests/test_time_rounding.py | 170 +- tests/test_time_rounding_param.py | 3 +- tests/test_timezone.py | 156 +- tests/test_ui_quick_wins.py | 14 +- tests/test_uploads_persistence.py | 295 +- tests/test_user_settings.py | 604 ++-- tests/test_utils.py | 304 +- tests/test_utils/test_webhook_service.py | 137 +- tests/test_version_reading.py | 25 +- tests/test_weekly_goals.py | 325 +- 373 files changed, 41539 insertions(+), 42935 deletions(-) create mode 100644 docs/testing/TEST_PERFORMANCE_OPTIMIZATIONS.md diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index f5c7c201..1dc4400b 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -150,8 +150,8 @@ jobs: FLASK_ENV: testing PYTHONPATH: ${{ github.workspace }} run: | - pytest -v --cov=app --cov-report=xml --cov-report=html --cov-report=term \ - --junitxml=junit.xml + pytest -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing \ + --junitxml=junit.xml --maxfail=5 - name: Upload coverage reports uses: codecov/codecov-action@v4 diff --git a/.github/workflows/ci-comprehensive.yml b/.github/workflows/ci-comprehensive.yml index 6a204664..e3646b02 100644 --- a/.github/workflows/ci-comprehensive.yml +++ b/.github/workflows/ci-comprehensive.yml @@ -98,9 +98,9 @@ jobs: PYTHONPATH: ${{ github.workspace }} run: | if [ "${{ matrix.test-group }}" == "api" ]; then - pytest -m "api and integration" -v --cov=app --cov-report=xml --cov-report=html + pytest -m "api and integration" -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing else - pytest -m "unit and ${{ matrix.test-group }}" -v --cov=app --cov-report=xml --cov-report=html + pytest -m "unit and ${{ matrix.test-group }}" -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing fi - name: Upload coverage to Codecov @@ -166,7 +166,7 @@ jobs: FLASK_ENV: testing PYTHONPATH: ${{ github.workspace }} run: | - pytest -m integration -v --cov=app --cov-report=xml --cov-report=html + pytest -m integration -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 @@ -452,8 +452,8 @@ jobs: FLASK_ENV: testing PYTHONPATH: ${{ github.workspace }} run: | - pytest -v --cov=app --cov-report=xml --cov-report=html --cov-report=term \ - --junitxml=junit.xml + pytest -v -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing \ + --junitxml=junit.xml --maxfail=5 - name: Upload full coverage uses: codecov/codecov-action@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b2a7997..cf5505a8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,9 @@ name: CI/CD Pipeline on: - push: - branches: [ main, develop ] pull_request: - branches: [ main, develop ] + branches: [ main ] + types: [ opened, synchronize, reopened, ready_for_review ] env: PYTHON_VERSION: '3.11' @@ -14,6 +13,7 @@ jobs: lint: name: Lint and Code Quality runs-on: ubuntu-latest + if: github.event.pull_request.head.ref == 'rc' || startsWith(github.event.pull_request.head.ref, 'rc/') steps: - uses: actions/checkout@v4 @@ -49,6 +49,7 @@ jobs: test: name: Test Suite runs-on: ubuntu-latest + if: github.event.pull_request.head.ref == 'rc' || startsWith(github.event.pull_request.head.ref, 'rc/') services: postgres: @@ -91,7 +92,7 @@ jobs: FLASK_ENV: testing SECRET_KEY: test-secret-key-for-ci run: | - pytest --cov=app --cov-report=xml --cov-report=html --cov-report=term tests/ + pytest -n auto --cov=app --cov-report=xml --cov-report=html --cov-report=term-missing tests/ - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 @@ -104,6 +105,7 @@ jobs: security: name: Security Scan runs-on: ubuntu-latest + if: github.event.pull_request.head.ref == 'rc' || startsWith(github.event.pull_request.head.ref, 'rc/') steps: - uses: actions/checkout@v4 diff --git a/app/__init__.py b/app/__init__.py index 321eda84..10328110 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -42,15 +42,17 @@ # Initialize Mail (will be configured in create_app) from flask_mail import Mail + mail = Mail() # Initialize APScheduler for background tasks from apscheduler.schedulers.background import BackgroundScheduler + scheduler = BackgroundScheduler() # Initialize Prometheus metrics -REQUEST_COUNT = Counter('tt_requests_total', 'Total requests', ['method', 'endpoint', 'http_status']) -REQUEST_LATENCY = Histogram('tt_request_latency_seconds', 'Request latency seconds', ['endpoint']) +REQUEST_COUNT = Counter("tt_requests_total", "Total requests", ["method", "endpoint", "http_status"]) +REQUEST_LATENCY = Histogram("tt_request_latency_seconds", "Request latency seconds", ["endpoint"]) # Initialize JSON logger for structured logging json_logger = logging.getLogger("timetracker") @@ -70,10 +72,10 @@ def log_event(name: str, **kwargs): def identify_user(user_id, properties=None): """ Identify a user in PostHog with person properties. - + Sets properties on the user for better segmentation, cohort analysis, and personalization in PostHog. - + Args: user_id: The user ID (internal ID, not PII) properties: Dict of properties to set (use $set and $set_once) @@ -82,11 +84,8 @@ def identify_user(user_id, properties=None): posthog_api_key = os.getenv("POSTHOG_API_KEY", "") if not posthog_api_key: return - - posthog.identify( - distinct_id=str(user_id), - properties=properties or {} - ) + + posthog.identify(distinct_id=str(user_id), properties=properties or {}) except Exception: # Don't let analytics errors break the application pass @@ -95,10 +94,10 @@ def identify_user(user_id, properties=None): def track_event(user_id, event_name, properties=None): """ Track a product analytics event via PostHog. - + Enhanced to include contextual properties like user agent, referrer, and deployment info for better analysis. - + Args: user_id: The user ID (internal ID, not PII) event_name: Name of the event (use resource.action format) @@ -109,40 +108,41 @@ def track_event(user_id, event_name, properties=None): posthog_api_key = os.getenv("POSTHOG_API_KEY", "") if not posthog_api_key: return - + # Enhance properties with context enhanced_properties = properties or {} - + # Add request context if available try: if request: - enhanced_properties.update({ - "$current_url": request.url, - "$host": request.host, - "$pathname": request.path, - "$browser": request.user_agent.browser, - "$device_type": "mobile" if request.user_agent.platform in ["android", "iphone"] else "desktop", - "$os": request.user_agent.platform, - }) + enhanced_properties.update( + { + "$current_url": request.url, + "$host": request.host, + "$pathname": request.path, + "$browser": request.user_agent.browser, + "$device_type": "mobile" if request.user_agent.platform in ["android", "iphone"] else "desktop", + "$os": request.user_agent.platform, + } + ) except Exception: pass - + # Add deployment context # Get app version from analytics config from app.config.analytics_defaults import get_analytics_config + analytics_config = get_analytics_config() - - enhanced_properties.update({ - "environment": os.getenv("FLASK_ENV", "production"), - "app_version": analytics_config.get("app_version"), - "deployment_method": "docker" if os.path.exists("/.dockerenv") else "native", - }) - - posthog.capture( - distinct_id=str(user_id), - event=event_name, - properties=enhanced_properties + + enhanced_properties.update( + { + "environment": os.getenv("FLASK_ENV", "production"), + "app_version": analytics_config.get("app_version"), + "deployment_method": "docker" if os.path.exists("/.dockerenv") else "native", + } ) + + posthog.capture(distinct_id=str(user_id), event=event_name, properties=enhanced_properties) except Exception: # Don't let analytics errors break the application pass @@ -151,7 +151,7 @@ def track_event(user_id, event_name, properties=None): def track_page_view(page_name, user_id=None, properties=None): """ Track a page view event. - + Args: page_name: Name of the page (e.g., 'dashboard', 'projects_list') user_id: User ID (optional, will use current_user if not provided) @@ -161,22 +161,23 @@ def track_page_view(page_name, user_id=None, properties=None): # Get user ID if not provided if user_id is None: from flask_login import current_user + if current_user.is_authenticated: user_id = current_user.id else: return # Don't track anonymous page views - + # Build page view properties page_properties = { "page_name": page_name, "$pathname": request.path if request else None, "$current_url": request.url if request else None, } - + # Add custom properties if provided if properties: page_properties.update(properties) - + # Track the page view track_event(user_id, "$pageview", page_properties) except Exception: @@ -192,21 +193,22 @@ def create_app(config=None): # Validate environment variables on startup (non-blocking warnings in dev, errors in prod) try: from app.utils.env_validation import validate_all + is_production = os.getenv("FLASK_ENV", "production") == "production" is_valid, results = validate_all(raise_on_error=is_production) - + if not is_valid: if is_production: app.logger.error("Environment validation failed - see details below") else: app.logger.warning("Environment validation warnings - see details below") - - if results.get('warnings'): - for warning in results['warnings']: + + if results.get("warnings"): + for warning in results["warnings"]: app.logger.warning(f" - {warning}") - - if results.get('production', {}).get('issues'): - for issue in results['production']['issues']: + + if results.get("production", {}).get("issues"): + for issue in results["production"]["issues"]: if is_production: app.logger.error(f" - {issue}") else: @@ -245,7 +247,12 @@ def create_app(config=None): except Exception: pass db_uri = str(app.config.get("SQLALCHEMY_DATABASE_URI", "") or "") - if app.config.get("TESTING") and isinstance(db_uri, str) and db_uri.startswith("sqlite") and ":memory:" in db_uri: + if ( + app.config.get("TESTING") + and isinstance(db_uri, str) + and db_uri.startswith("sqlite") + and ":memory:" in db_uri + ): # Use a file-based SQLite database during tests to ensure consistent behavior across contexts db_file = os.path.join(tempfile.gettempdir(), f"timetracker_pytest_{os.getpid()}.sqlite") app.config["SQLALCHEMY_DATABASE_URI"] = f"sqlite:///{db_file}" @@ -263,12 +270,8 @@ def create_app(config=None): pass # Add top-level templates directory in addition to app/templates - extra_templates_path = os.path.abspath( - os.path.join(app.root_path, "..", "templates") - ) - app.jinja_loader = ChoiceLoader( - [app.jinja_loader, FileSystemLoader(extra_templates_path)] - ) + extra_templates_path = os.path.abspath(os.path.join(app.root_path, "..", "templates")) + app.jinja_loader = ChoiceLoader([app.jinja_loader, FileSystemLoader(extra_templates_path)]) # Prefer Postgres if POSTGRES_* envs are present but URL points to SQLite current_url = app.config.get("SQLALCHEMY_DATABASE_URI", "") @@ -276,19 +279,13 @@ def create_app(config=None): not app.config.get("TESTING") and isinstance(current_url, str) and current_url.startswith("sqlite") - and ( - os.getenv("POSTGRES_DB") - or os.getenv("POSTGRES_USER") - or os.getenv("POSTGRES_PASSWORD") - ) + and (os.getenv("POSTGRES_DB") or os.getenv("POSTGRES_USER") or os.getenv("POSTGRES_PASSWORD")) ): pg_user = os.getenv("POSTGRES_USER", "timetracker") pg_pass = os.getenv("POSTGRES_PASSWORD", "timetracker") pg_db = os.getenv("POSTGRES_DB", "timetracker") pg_host = os.getenv("POSTGRES_HOST", "db") - app.config["SQLALCHEMY_DATABASE_URI"] = ( - f"postgresql+psycopg2://{pg_user}:{pg_pass}@{pg_host}:5432/{pg_db}" - ) + app.config["SQLALCHEMY_DATABASE_URI"] = f"postgresql+psycopg2://{pg_user}:{pg_pass}@{pg_host}:5432/{pg_db}" # Initialize extensions db.init_app(app) @@ -296,12 +293,13 @@ def create_app(config=None): login_manager.init_app(app) socketio.init_app(app, cors_allowed_origins="*") oauth.init_app(app) - + # Initialize Settings from environment variables on startup # This ensures .env values are used as initial values, but WebUI changes take priority with app.app_context(): try: from app.models import Settings + # This will create Settings if it doesn't exist and initialize from .env # The get_settings() method automatically initializes new Settings from .env Settings.get_settings() @@ -309,21 +307,23 @@ def create_app(config=None): # Don't fail app startup if Settings initialization fails # (e.g., database not ready yet, migration not run) app.logger.warning(f"Could not initialize Settings from environment: {e}") - + # Initialize Flask-Mail from app.utils.email import init_mail + init_mail(app) - + # Initialize and start background scheduler (disabled in tests) if (not app.config.get("TESTING")) and (not scheduler.running): from app.utils.scheduled_tasks import register_scheduled_tasks + scheduler.start() # Register tasks after app context is available, passing app instance with app.app_context(): register_scheduled_tasks(scheduler, app=app) - + # Only initialize CSRF protection if enabled - if app.config.get('WTF_CSRF_ENABLED'): + if app.config.get("WTF_CSRF_ENABLED"): csrf.init_app(app) try: # Configure limiter defaults from config if provided @@ -331,9 +331,7 @@ def create_app(config=None): raw = app.config.get("RATELIMIT_DEFAULT") if raw: # support semicolon or comma separated limits - parts = [ - p.strip() for p in str(raw).replace(",", ";").split(";") if p.strip() - ] + parts = [p.strip() for p in str(raw).replace(",", ";").split(";") if p.strip()] if parts: default_limits = parts limiter._default_limits = default_limits # set after init @@ -343,18 +341,14 @@ def create_app(config=None): # Ensure translations exist and configure absolute translation directories before Babel init try: - translations_dirs = ( - app.config.get("BABEL_TRANSLATION_DIRECTORIES") or "translations" - ).split(",") + translations_dirs = (app.config.get("BABEL_TRANSLATION_DIRECTORIES") or "translations").split(",") base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) abs_dirs = [] for d in translations_dirs: d = d.strip() if not d: continue - abs_dirs.append( - d if os.path.isabs(d) else os.path.abspath(os.path.join(base_path, d)) - ) + abs_dirs.append(d if os.path.isabs(d) else os.path.abspath(os.path.join(base_path, d))) if abs_dirs: app.config["BABEL_TRANSLATION_DIRECTORIES"] = os.pathsep.join(abs_dirs) # Best-effort compile with Babel CLI if available, else Python fallback @@ -387,30 +381,28 @@ def _select_locale(): return _normalize_locale(session.get("preferred_language")) # 3) Best match with Accept-Language supported = list(app.config.get("LANGUAGES", {}).keys()) or ["en"] - matched = request.accept_languages.best_match(supported) or app.config.get( - "BABEL_DEFAULT_LOCALE", "en" - ) + matched = request.accept_languages.best_match(supported) or app.config.get("BABEL_DEFAULT_LOCALE", "en") return _normalize_locale(matched) except Exception: return app.config.get("BABEL_DEFAULT_LOCALE", "en") - + def _normalize_locale(locale_code): """Normalize locale codes for Flask-Babel compatibility. - + Some locale codes need to be normalized: - 'no' -> 'nb' (Norwegian Bokmål is the standard, but we'll try 'no' first) """ if not locale_code: - return 'en' + return "en" locale_code = locale_code.lower().strip() # Try 'no' first - if translations don't exist, Flask-Babel will fall back # If 'no' doesn't work, we can map to 'nb' as fallback # For now, keep 'no' as-is since we have translations/nb/ directory # The directory structure should match what Flask-Babel expects - if locale_code == 'no': + if locale_code == "no": # Use 'nb' for Flask-Babel (standard Norwegian Bokmål locale) # But ensure we have translations in both 'no' and 'nb' directories - return 'nb' + return "nb" return locale_code babel.init_app( @@ -471,10 +463,12 @@ def _ensure_user_authenticated_in_tests(): try: if app.config.get("TESTING"): from flask_login import current_user, login_user + if not getattr(current_user, "is_authenticated", False): uid = session.get("_user_id") or session.get("user_id") if uid: from app.models import User + user = User.query.get(int(uid)) if user and getattr(user, "is_active", True): login_user(user, remember=True) @@ -495,32 +489,40 @@ def load_user(user_id): def check_setup_required(): try: # Skip setup check in testing mode - if app.config.get('TESTING'): + if app.config.get("TESTING"): return - + # Skip setup check for these routes - skip_routes = ['setup.initial_setup', 'static', 'auth.login', 'auth.logout', 'main.health_check', 'main.readiness_check'] + skip_routes = [ + "setup.initial_setup", + "static", + "auth.login", + "auth.logout", + "main.health_check", + "main.readiness_check", + ] if request.endpoint in skip_routes: return - + # Skip for assets and health checks - if request.path.startswith('/static/') or request.path.startswith('/_'): + if request.path.startswith("/static/") or request.path.startswith("/_"): return - + # Check if setup is complete from app.utils.installation import get_installation_config + installation_config = get_installation_config() - + if not installation_config.is_setup_complete(): - return redirect(url_for('setup.initial_setup')) + return redirect(url_for("setup.initial_setup")) except Exception: pass - + # Attach request ID for tracing @app.before_request def attach_request_id(): try: - g.request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4()) + g.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4()) except Exception: pass @@ -552,17 +554,13 @@ def log_login_requests(): def record_metrics_and_log(response): try: # Record Prometheus metrics - latency = time.time() - getattr(g, '_start_time', time.time()) + latency = time.time() - getattr(g, "_start_time", time.time()) endpoint = request.endpoint or "unknown" REQUEST_LATENCY.labels(endpoint=endpoint).observe(latency) - REQUEST_COUNT.labels( - method=request.method, - endpoint=endpoint, - http_status=response.status_code - ).inc() + REQUEST_COUNT.labels(method=request.method, endpoint=endpoint, http_status=response.status_code).inc() except Exception: pass - + try: # Log write operations if request.method in ("POST", "PUT", "PATCH", "DELETE"): @@ -578,17 +576,16 @@ def record_metrics_and_log(response): return response # Configure session - app.config["PERMANENT_SESSION_LIFETIME"] = timedelta( - seconds=int(os.getenv("PERMANENT_SESSION_LIFETIME", 86400)) - ) + app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(seconds=int(os.getenv("PERMANENT_SESSION_LIFETIME", 86400))) # Setup logging (including JSON logging) setup_logging(app) - + # Enable query logging in development mode - if app.config.get('FLASK_DEBUG') or app.config.get('TESTING'): + if app.config.get("FLASK_DEBUG") or app.config.get("TESTING"): try: from app.utils.query_logging import enable_query_logging, enable_query_counting + enable_query_logging(app, slow_query_threshold=0.1) enable_query_counting(app) app.logger.info("Query logging enabled (development mode)") @@ -597,14 +594,15 @@ def record_metrics_and_log(response): # Load analytics configuration (embedded at build time) from app.config.analytics_defaults import get_analytics_config, has_analytics_configured + analytics_config = get_analytics_config() - + # Log analytics status (for transparency) if has_analytics_configured(): app.logger.info("TimeTracker with analytics configured (telemetry opt-in via admin dashboard)") else: app.logger.info("TimeTracker build without analytics configuration") - + # Initialize Sentry for error monitoring # Priority: Env var > Built-in default > Disabled sentry_dsn = analytics_config.get("sentry_dsn", "") @@ -615,7 +613,7 @@ def record_metrics_and_log(response): integrations=[FlaskIntegration()], traces_sample_rate=analytics_config.get("sentry_traces_rate", 0.0), environment=os.getenv("FLASK_ENV", "production"), - release=analytics_config.get("app_version") + release=analytics_config.get("app_version"), ) app.logger.info("Sentry error monitoring initialized") except Exception as e: @@ -636,7 +634,11 @@ def record_metrics_and_log(response): # Fail-fast on weak/missing secret in production if not app.debug and app.config.get("FLASK_ENV", "production") == "production": secret = app.config.get("SECRET_KEY") - placeholder_values = {"dev-secret-key-change-in-production", "your-secret-key-change-this", "your-secret-key-here"} + placeholder_values = { + "dev-secret-key-change-in-production", + "your-secret-key-change-this", + "your-secret-key-here", + } if (not secret) or (secret in placeholder_values) or (isinstance(secret, str) and len(secret) < 32): app.logger.error("Invalid SECRET_KEY configured in production; refusing to start") raise RuntimeError("Invalid SECRET_KEY in production") @@ -666,16 +668,14 @@ def apply_security_headers(response): if not response.headers.get("Referrer-Policy"): response.headers["Referrer-Policy"] = "no-referrer" if not response.headers.get("Permissions-Policy"): - response.headers["Permissions-Policy"] = ( - "geolocation=(), microphone=(), camera=()" - ) + response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" except Exception: pass # CSRF cookie/token handling # If CSRF is enabled, ensure CSRF cookie exists for HTML GET responses # If CSRF is disabled, explicitly clear any existing CSRF cookie to avoid confusion - if app.config.get('WTF_CSRF_ENABLED'): + if app.config.get("WTF_CSRF_ENABLED"): try: # Only for safe, HTML page responses if request.method == "GET": @@ -687,6 +687,7 @@ def apply_security_headers(response): # Generate a CSRF token and set cookie using same settings as /auth/csrf-token try: from flask_wtf.csrf import generate_csrf + token = generate_csrf() except Exception: token = "" @@ -728,7 +729,9 @@ def apply_security_headers(response): expires=0, path=app.config.get("CSRF_COOKIE_PATH", "/"), domain=app.config.get("CSRF_COOKIE_DOMAIN") or None, - secure=bool(app.config.get("CSRF_COOKIE_SECURE", app.config.get("SESSION_COOKIE_SECURE", False))), + secure=bool( + app.config.get("CSRF_COOKIE_SECURE", app.config.get("SESSION_COOKIE_SECURE", False)) + ), httponly=bool(app.config.get("CSRF_COOKIE_HTTPONLY", False)), samesite=app.config.get("CSRF_COOKIE_SAMESITE", "Lax"), ) @@ -750,6 +753,7 @@ def handle_csrf_error(e): try: try: from flask_login import current_user as _cu + user_id = getattr(_cu, "id", None) if getattr(_cu, "is_authenticated", False) else None except Exception: user_id = None @@ -761,7 +765,7 @@ def handle_csrf_error(e): request.is_json, request.referrer, user_id, - getattr(e, "description", "") + getattr(e, "description", ""), ) except Exception: pass @@ -790,8 +794,7 @@ def handle_csrf_error(e): wants_json = ( request.is_json or request.headers.get("X-Requested-With") == "XMLHttpRequest" - or request.accept_mimetypes["application/json"] - >= request.accept_mimetypes["text/html"] + or request.accept_mimetypes["application/json"] >= request.accept_mimetypes["text/html"] ) except Exception: wants_json = False @@ -822,24 +825,26 @@ def handle_csrf_error(e): def inject_csrf_token(): def get_csrf_token(): # Return empty string if CSRF is disabled - if not app.config.get('WTF_CSRF_ENABLED'): + if not app.config.get("WTF_CSRF_ENABLED"): return "" try: from flask_wtf.csrf import generate_csrf + return generate_csrf() except Exception: return "" + return dict(csrf_token=get_csrf_token) # CSRF token refresh endpoint (GET) @app.route("/auth/csrf-token", methods=["GET"]) def get_csrf_token(): # If CSRF is disabled, return empty token - if not app.config.get('WTF_CSRF_ENABLED'): + if not app.config.get("WTF_CSRF_ENABLED"): resp = jsonify(csrf_token="", csrf_enabled=False) resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" return resp - + try: from flask_wtf.csrf import generate_csrf @@ -885,7 +890,7 @@ def get_csrf_token(): # Initialize audit logging (import to register event listeners) from app.utils import audit # noqa: F401 - + # Register blueprints from app.routes.auth import auth_bp from app.routes.main import main_bp @@ -927,8 +932,10 @@ def get_csrf_token(): from app.routes.deals import deals_bp from app.routes.leads import leads_bp from app.routes.kiosk import kiosk_bp + try: from app.routes.audit_logs import audit_logs_bp + app.register_blueprint(audit_logs_bp) except Exception as e: # Log error but don't fail app startup @@ -977,67 +984,76 @@ def get_csrf_token(): app.register_blueprint(deals_bp) app.register_blueprint(leads_bp) # audit_logs_bp is registered above with error handling - + # Register integration connectors try: from app.integrations import registry + # Connectors are auto-registered on import logger.info("Integration connectors registered") except Exception as e: logger.warning(f"Could not register integration connectors: {e}") - + # Register new feature blueprints try: from app.routes.project_templates import project_templates_bp + app.register_blueprint(project_templates_bp) except Exception as e: logger.warning(f"Could not register project_templates blueprint: {e}") - + try: from app.routes.invoice_approvals import invoice_approvals_bp + app.register_blueprint(invoice_approvals_bp) except Exception as e: logger.warning(f"Could not register invoice_approvals blueprint: {e}") - + try: from app.routes.payment_gateways import payment_gateways_bp + app.register_blueprint(payment_gateways_bp) except Exception as e: logger.warning(f"Could not register payment_gateways blueprint: {e}") - + try: from app.routes.scheduled_reports import scheduled_reports_bp + app.register_blueprint(scheduled_reports_bp) except Exception as e: logger.warning(f"Could not register scheduled_reports blueprint: {e}") - + try: from app.routes.integrations import integrations_bp + app.register_blueprint(integrations_bp) except Exception as e: logger.warning(f"Could not register integrations blueprint: {e}") - + try: from app.routes.push_notifications import push_bp + app.register_blueprint(push_bp) except Exception as e: logger.warning(f"Could not register push_notifications blueprint: {e}") - + try: from app.routes.custom_reports import custom_reports_bp + app.register_blueprint(custom_reports_bp) except Exception as e: logger.warning(f"Could not register custom_reports blueprint: {e}") - + try: from app.routes.gantt import gantt_bp + app.register_blueprint(gantt_bp) except Exception as e: logger.warning(f"Could not register gantt blueprint: {e}") # Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens) # Only if CSRF is enabled - if app.config.get('WTF_CSRF_ENABLED'): + if app.config.get("WTF_CSRF_ENABLED"): csrf.exempt(api_bp) csrf.exempt(api_v1_bp) csrf.exempt(api_docs_bp) @@ -1075,10 +1091,10 @@ def get_csrf_token(): ) # Prometheus metrics endpoint - @app.route('/metrics') + @app.route("/metrics") def metrics(): """Expose Prometheus metrics""" - return generate_latest(), 200, {'Content-Type': CONTENT_TYPE_LATEST} + return generate_latest(), 200, {"Content-Type": CONTENT_TYPE_LATEST} # Register error handlers from app.utils.error_handlers import register_error_handlers @@ -1087,12 +1103,12 @@ def metrics(): # Register context processors from app.utils.context_processors import register_context_processors - + register_context_processors(app) - + # Register i18n template filters from app.utils.i18n_helpers import register_i18n_filters - + register_i18n_filters(app) # (translations compiled and directories set before Babel init) @@ -1115,9 +1131,7 @@ def _promote_admin_users_on_request(): if not current_user or not getattr(current_user, "is_authenticated", False): return - admin_usernames = [ - u.strip().lower() for u in app.config.get("ADMIN_USERNAMES", ["admin"]) - ] + admin_usernames = [u.strip().lower() for u in app.config.get("ADMIN_USERNAMES", ["admin"])] if ( current_user.username and current_user.username.lower() in admin_usernames @@ -1177,18 +1191,12 @@ def setup_logging(app): log_level = os.getenv("LOG_LEVEL", "INFO") # Default to a file in the project logs directory if not provided default_log_path = os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "logs", "timetracker.log" - ) + os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "timetracker.log") ) log_file = os.getenv("LOG_FILE", default_log_path) - + # JSON log file path - json_log_path = os.path.abspath( - os.path.join( - os.path.dirname(os.path.dirname(__file__)), "logs", "app.jsonl" - ) - ) + json_log_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "app.jsonl")) # Prepare handlers handlers = [logging.StreamHandler()] @@ -1211,11 +1219,7 @@ def setup_logging(app): # Configure Flask app logger directly (works well under gunicorn) for handler in handlers: handler.setLevel(getattr(logging, log_level.upper())) - handler.setFormatter( - logging.Formatter( - "%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]" - ) - ) + handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]")) # Clear existing handlers to avoid duplicate logs app.logger.handlers.clear() @@ -1237,19 +1241,17 @@ def setup_logging(app): json_log_dir = os.path.dirname(json_log_path) if json_log_dir and not os.path.exists(json_log_dir): os.makedirs(json_log_dir, exist_ok=True) - + json_handler = logging.FileHandler(json_log_path) - json_formatter = jsonlogger.JsonFormatter( - '%(asctime)s %(levelname)s %(name)s %(message)s' - ) + json_formatter = jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s") json_handler.setFormatter(json_formatter) json_handler.setLevel(logging.INFO) - + # Add JSON handler to the timetracker logger json_logger.handlers.clear() json_logger.addHandler(json_handler) json_logger.propagate = False - + app.logger.info(f"JSON logging initialized: {json_log_path}") except (PermissionError, OSError) as e: app.logger.warning(f"Could not initialize JSON logging: {e}") @@ -1278,36 +1280,24 @@ def migrate_task_management_tables(): # Check if task_id column exists in time_entries table if "time_entries" in existing_tables: - time_entries_columns = [ - col["name"] for col in inspector.get_columns("time_entries") - ] + time_entries_columns = [col["name"] for col in inspector.get_columns("time_entries")] if "task_id" not in time_entries_columns: print("Task Management: Adding task_id column to time_entries table...") try: # Add task_id column to time_entries table - db.engine.execute( - text( - "ALTER TABLE time_entries ADD COLUMN task_id INTEGER REFERENCES tasks(id)" - ) - ) + db.engine.execute(text("ALTER TABLE time_entries ADD COLUMN task_id INTEGER REFERENCES tasks(id)")) print("✓ task_id column added to time_entries table") except Exception as e: print(f"⚠ Warning: Could not add task_id column: {e}") - print( - " You may need to manually add this column or recreate the database" - ) + print(" You may need to manually add this column or recreate the database") else: - print( - "Task Management: task_id column already exists in time_entries table" - ) + print("Task Management: task_id column already exists in time_entries table") print("Task Management migration check completed") except Exception as e: print(f"⚠ Warning: Task Management migration check failed: {e}") - print( - " The application will continue, but Task Management features may not work properly" - ) + print(" The application will continue, but Task Management features may not work properly") def init_database(app): diff --git a/app/config.py b/app/config.py index 37d588a7..54bee5a9 100644 --- a/app/config.py +++ b/app/config.py @@ -1,186 +1,190 @@ import os from datetime import timedelta + class Config: """Base configuration class""" - + # Flask settings - SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') - FLASK_ENV = os.getenv('FLASK_ENV', 'production') - FLASK_DEBUG = os.getenv('FLASK_DEBUG', 'false').lower() == 'true' - + SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production") + FLASK_ENV = os.getenv("FLASK_ENV", "production") + FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true" + # Database settings (default to PostgreSQL) SQLALCHEMY_DATABASE_URI = os.getenv( - 'DATABASE_URL', - 'postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker' + "DATABASE_URL", "postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker" ) SQLALCHEMY_TRACK_MODIFICATIONS = False SQLALCHEMY_ENGINE_OPTIONS = { - 'pool_pre_ping': True, - 'pool_recycle': 300, + "pool_pre_ping": True, + "pool_recycle": 300, } - + # Session settings - SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true' - SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true' - SESSION_COOKIE_SAMESITE = os.getenv('SESSION_COOKIE_SAMESITE', 'Lax') - PERMANENT_SESSION_LIFETIME = timedelta( - seconds=int(os.getenv('PERMANENT_SESSION_LIFETIME', 86400)) - ) + SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "false").lower() == "true" + SESSION_COOKIE_HTTPONLY = os.getenv("SESSION_COOKIE_HTTPONLY", "true").lower() == "true" + SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "Lax") + PERMANENT_SESSION_LIFETIME = timedelta(seconds=int(os.getenv("PERMANENT_SESSION_LIFETIME", 86400))) # Flask-Login remember cookie settings - REMEMBER_COOKIE_DURATION = timedelta(days=int(os.getenv('REMEMBER_COOKIE_DAYS', 365))) - REMEMBER_COOKIE_SECURE = os.getenv('REMEMBER_COOKIE_SECURE', 'false').lower() == 'true' + REMEMBER_COOKIE_DURATION = timedelta(days=int(os.getenv("REMEMBER_COOKIE_DAYS", 365))) + REMEMBER_COOKIE_SECURE = os.getenv("REMEMBER_COOKIE_SECURE", "false").lower() == "true" REMEMBER_COOKIE_HTTPONLY = True - REMEMBER_COOKIE_SAMESITE = os.getenv('REMEMBER_COOKIE_SAMESITE', 'Lax') - + REMEMBER_COOKIE_SAMESITE = os.getenv("REMEMBER_COOKIE_SAMESITE", "Lax") + # Application settings - TZ = os.getenv('TZ', 'Europe/Rome') - CURRENCY = os.getenv('CURRENCY', 'EUR') - ROUNDING_MINUTES = int(os.getenv('ROUNDING_MINUTES', 1)) - SINGLE_ACTIVE_TIMER = os.getenv('SINGLE_ACTIVE_TIMER', 'true').lower() == 'true' - IDLE_TIMEOUT_MINUTES = int(os.getenv('IDLE_TIMEOUT_MINUTES', 30)) - + TZ = os.getenv("TZ", "Europe/Rome") + CURRENCY = os.getenv("CURRENCY", "EUR") + ROUNDING_MINUTES = int(os.getenv("ROUNDING_MINUTES", 1)) + SINGLE_ACTIVE_TIMER = os.getenv("SINGLE_ACTIVE_TIMER", "true").lower() == "true" + IDLE_TIMEOUT_MINUTES = int(os.getenv("IDLE_TIMEOUT_MINUTES", 30)) + # User management - ALLOW_SELF_REGISTER = os.getenv('ALLOW_SELF_REGISTER', 'true').lower() == 'true' - ADMIN_USERNAMES = os.getenv('ADMIN_USERNAMES', 'admin').split(',') + ALLOW_SELF_REGISTER = os.getenv("ALLOW_SELF_REGISTER", "true").lower() == "true" + ADMIN_USERNAMES = os.getenv("ADMIN_USERNAMES", "admin").split(",") # Authentication method: 'none' | 'local' | 'oidc' | 'both' # 'none' = no password authentication (username only) # 'local' = password authentication required # 'oidc' = OIDC/Single Sign-On only # 'both' = OIDC + local password authentication - AUTH_METHOD = os.getenv('AUTH_METHOD', 'local').strip().lower() + AUTH_METHOD = os.getenv("AUTH_METHOD", "local").strip().lower() # OIDC settings (used when AUTH_METHOD is 'oidc' or 'both') - OIDC_ISSUER = os.getenv('OIDC_ISSUER') # e.g., https://login.microsoftonline.com//v2.0 - OIDC_CLIENT_ID = os.getenv('OIDC_CLIENT_ID') - OIDC_CLIENT_SECRET = os.getenv('OIDC_CLIENT_SECRET') - OIDC_REDIRECT_URI = os.getenv('OIDC_REDIRECT_URI') # e.g., https://app.example.com/auth/oidc/callback - OIDC_SCOPES = os.getenv('OIDC_SCOPES', 'openid profile email') - OIDC_USERNAME_CLAIM = os.getenv('OIDC_USERNAME_CLAIM', 'preferred_username') - OIDC_FULL_NAME_CLAIM = os.getenv('OIDC_FULL_NAME_CLAIM', 'name') - OIDC_EMAIL_CLAIM = os.getenv('OIDC_EMAIL_CLAIM', 'email') - OIDC_GROUPS_CLAIM = os.getenv('OIDC_GROUPS_CLAIM', 'groups') - OIDC_ADMIN_GROUP = os.getenv('OIDC_ADMIN_GROUP') # optional - OIDC_ADMIN_EMAILS = [e.strip().lower() for e in os.getenv('OIDC_ADMIN_EMAILS', '').split(',') if e.strip()] - OIDC_POST_LOGOUT_REDIRECT_URI = os.getenv('OIDC_POST_LOGOUT_REDIRECT_URI') - + OIDC_ISSUER = os.getenv("OIDC_ISSUER") # e.g., https://login.microsoftonline.com//v2.0 + OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID") + OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET") + OIDC_REDIRECT_URI = os.getenv("OIDC_REDIRECT_URI") # e.g., https://app.example.com/auth/oidc/callback + OIDC_SCOPES = os.getenv("OIDC_SCOPES", "openid profile email") + OIDC_USERNAME_CLAIM = os.getenv("OIDC_USERNAME_CLAIM", "preferred_username") + OIDC_FULL_NAME_CLAIM = os.getenv("OIDC_FULL_NAME_CLAIM", "name") + OIDC_EMAIL_CLAIM = os.getenv("OIDC_EMAIL_CLAIM", "email") + OIDC_GROUPS_CLAIM = os.getenv("OIDC_GROUPS_CLAIM", "groups") + OIDC_ADMIN_GROUP = os.getenv("OIDC_ADMIN_GROUP") # optional + OIDC_ADMIN_EMAILS = [e.strip().lower() for e in os.getenv("OIDC_ADMIN_EMAILS", "").split(",") if e.strip()] + OIDC_POST_LOGOUT_REDIRECT_URI = os.getenv("OIDC_POST_LOGOUT_REDIRECT_URI") + # Backup settings - BACKUP_RETENTION_DAYS = int(os.getenv('BACKUP_RETENTION_DAYS', 30)) - BACKUP_TIME = os.getenv('BACKUP_TIME', '02:00') - + BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", 30)) + BACKUP_TIME = os.getenv("BACKUP_TIME", "02:00") + # Pagination ENTRIES_PER_PAGE = 50 PROJECTS_PER_PAGE = 20 - + # File upload settings MAX_CONTENT_LENGTH = 16 * 1024 * 1024 # 16MB max file size - UPLOAD_FOLDER = '/data/uploads' - + UPLOAD_FOLDER = "/data/uploads" + # CSRF protection - WTF_CSRF_ENABLED = os.getenv('WTF_CSRF_ENABLED', 'true').lower() == 'true' - WTF_CSRF_TIME_LIMIT = int(os.getenv('WTF_CSRF_TIME_LIMIT', 3600)) # Default: 1 hour + WTF_CSRF_ENABLED = os.getenv("WTF_CSRF_ENABLED", "true").lower() == "true" + WTF_CSRF_TIME_LIMIT = int(os.getenv("WTF_CSRF_TIME_LIMIT", 3600)) # Default: 1 hour # If true, rejects requests considered insecure for CSRF; keep strict in prod, relaxed in dev - WTF_CSRF_SSL_STRICT = os.getenv('WTF_CSRF_SSL_STRICT', 'true').lower() == 'true' + WTF_CSRF_SSL_STRICT = os.getenv("WTF_CSRF_SSL_STRICT", "true").lower() == "true" # Allow trusted cross-origin posts (behind proxies or when Referer/Origin host differs) # Comma-separated list of origins, e.g. "https://track.example.com,https://admin.example.com" WTF_CSRF_TRUSTED_ORIGINS = [ - o.strip() for o in os.getenv( - 'WTF_CSRF_TRUSTED_ORIGINS', 'https://track.example.com' - ).split(',') if o.strip() + o.strip() for o in os.getenv("WTF_CSRF_TRUSTED_ORIGINS", "https://track.example.com").split(",") if o.strip() ] # CSRF cookie settings (for double-submit cookie pattern and SPA helpers) - CSRF_COOKIE_NAME = os.getenv('CSRF_COOKIE_NAME', 'XSRF-TOKEN') - CSRF_COOKIE_SECURE = os.getenv('CSRF_COOKIE_SECURE', '').lower() + CSRF_COOKIE_NAME = os.getenv("CSRF_COOKIE_NAME", "XSRF-TOKEN") + CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "").lower() # default secure flag: inherit from SESSION_COOKIE_SECURE if unset - CSRF_COOKIE_SECURE = (CSRF_COOKIE_SECURE == 'true') if CSRF_COOKIE_SECURE in ('true','false') else SESSION_COOKIE_SECURE - CSRF_COOKIE_HTTPONLY = os.getenv('CSRF_COOKIE_HTTPONLY', 'false').lower() == 'true' - CSRF_COOKIE_SAMESITE = os.getenv('CSRF_COOKIE_SAMESITE', 'Lax') - CSRF_COOKIE_DOMAIN = os.getenv('CSRF_COOKIE_DOMAIN') - CSRF_COOKIE_PATH = os.getenv('CSRF_COOKIE_PATH', '/') - + CSRF_COOKIE_SECURE = ( + (CSRF_COOKIE_SECURE == "true") if CSRF_COOKIE_SECURE in ("true", "false") else SESSION_COOKIE_SECURE + ) + CSRF_COOKIE_HTTPONLY = os.getenv("CSRF_COOKIE_HTTPONLY", "false").lower() == "true" + CSRF_COOKIE_SAMESITE = os.getenv("CSRF_COOKIE_SAMESITE", "Lax") + CSRF_COOKIE_DOMAIN = os.getenv("CSRF_COOKIE_DOMAIN") + CSRF_COOKIE_PATH = os.getenv("CSRF_COOKIE_PATH", "/") + # Security headers SECURITY_HEADERS = { - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - 'X-XSS-Protection': '1; mode=block', - 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", # Allow same-origin Referer on HTTPS so CSRF checks that rely on Referer can pass - 'Referrer-Policy': 'strict-origin-when-cross-origin' + "Referrer-Policy": "strict-origin-when-cross-origin", } # Rate limiting - RATELIMIT_DEFAULT = os.getenv('RATELIMIT_DEFAULT', '') # e.g., "200 per day;50 per hour" - RATELIMIT_STORAGE_URI = os.getenv('RATELIMIT_STORAGE_URI', 'memory://') - + RATELIMIT_DEFAULT = os.getenv("RATELIMIT_DEFAULT", "") # e.g., "200 per day;50 per hour" + RATELIMIT_STORAGE_URI = os.getenv("RATELIMIT_STORAGE_URI", "memory://") + # Internationalization LANGUAGES = { - 'en': 'English', - 'nl': 'Nederlands', - 'de': 'Deutsch', - 'fr': 'Français', - 'it': 'Italiano', - 'fi': 'Suomi', - 'es': 'Español', - 'no': 'Norsk', - 'ar': 'العربية', - 'he': 'עברית', + "en": "English", + "nl": "Nederlands", + "de": "Deutsch", + "fr": "Français", + "it": "Italiano", + "fi": "Suomi", + "es": "Español", + "no": "Norsk", + "ar": "العربية", + "he": "עברית", } # RTL languages - RTL_LANGUAGES = {'ar', 'he'} - BABEL_DEFAULT_LOCALE = os.getenv('DEFAULT_LOCALE', 'en') + RTL_LANGUAGES = {"ar", "he"} + BABEL_DEFAULT_LOCALE = os.getenv("DEFAULT_LOCALE", "en") # Comma-separated list of translation directories relative to instance root - BABEL_TRANSLATION_DIRECTORIES = os.getenv('BABEL_TRANSLATION_DIRECTORIES', 'translations') - + BABEL_TRANSLATION_DIRECTORIES = os.getenv("BABEL_TRANSLATION_DIRECTORIES", "translations") + # Versioning # Prefer explicit app version from environment (e.g., Git tag) - APP_VERSION = os.getenv('APP_VERSION', os.getenv('GITHUB_TAG', None)) + APP_VERSION = os.getenv("APP_VERSION", os.getenv("GITHUB_TAG", None)) if not APP_VERSION: # If no tag provided, create a dev-build identifier if available - github_run_number = os.getenv('GITHUB_RUN_NUMBER') + github_run_number = os.getenv("GITHUB_RUN_NUMBER") APP_VERSION = f"dev-{github_run_number}" if github_run_number else "3.1.0" + class DevelopmentConfig(Config): """Development configuration""" + FLASK_DEBUG = True SQLALCHEMY_DATABASE_URI = os.getenv( - 'DATABASE_URL', - 'postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker' + "DATABASE_URL", "postgresql+psycopg2://timetracker:timetracker@localhost:5432/timetracker" ) # CSRF can be overridden via env var, defaults to False for dev convenience - WTF_CSRF_ENABLED = os.getenv('WTF_CSRF_ENABLED', 'false').lower() == 'true' + WTF_CSRF_ENABLED = os.getenv("WTF_CSRF_ENABLED", "false").lower() == "true" # Relax SSL strictness by default in dev to avoid false negatives on http - WTF_CSRF_SSL_STRICT = os.getenv('WTF_CSRF_SSL_STRICT', 'false').lower() == 'true' + WTF_CSRF_SSL_STRICT = os.getenv("WTF_CSRF_SSL_STRICT", "false").lower() == "true" + class TestingConfig(Config): """Testing configuration""" + TESTING = True # Allow DATABASE_URL override for CI/CD PostgreSQL testing # Default to in-memory SQLite for local unit tests - SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///:memory:') + SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///:memory:") WTF_CSRF_ENABLED = False - SECRET_KEY = 'test-secret-key' + SECRET_KEY = "test-secret-key" WTF_CSRF_SSL_STRICT = False def __init__(self): # Ensure SQLALCHEMY_DATABASE_URI reflects the current environment at instantiation time, # not only at module import time. This keeps parity with tests that mutate env vars. - self.SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL', 'sqlite:///:memory:') + self.SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///:memory:") + class ProductionConfig(Config): """Production configuration""" + FLASK_DEBUG = False # Honor environment with secure-by-default values in production - SESSION_COOKIE_SECURE = os.getenv('SESSION_COOKIE_SECURE', 'true').lower() == 'true' - SESSION_COOKIE_HTTPONLY = os.getenv('SESSION_COOKIE_HTTPONLY', 'true').lower() == 'true' - REMEMBER_COOKIE_SECURE = os.getenv('REMEMBER_COOKIE_SECURE', 'true').lower() == 'true' - WTF_CSRF_ENABLED = os.getenv('WTF_CSRF_ENABLED', 'true').lower() == 'true' - WTF_CSRF_SSL_STRICT = os.getenv('WTF_CSRF_SSL_STRICT', 'true').lower() == 'true' + SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "true").lower() == "true" + SESSION_COOKIE_HTTPONLY = os.getenv("SESSION_COOKIE_HTTPONLY", "true").lower() == "true" + REMEMBER_COOKIE_SECURE = os.getenv("REMEMBER_COOKIE_SECURE", "true").lower() == "true" + WTF_CSRF_ENABLED = os.getenv("WTF_CSRF_ENABLED", "true").lower() == "true" + WTF_CSRF_SSL_STRICT = os.getenv("WTF_CSRF_SSL_STRICT", "true").lower() == "true" + # Configuration mapping config = { - 'development': DevelopmentConfig, - 'testing': TestingConfig, - 'production': ProductionConfig, + "development": DevelopmentConfig, + "testing": TestingConfig, + "production": ProductionConfig, } diff --git a/app/config/__init__.py b/app/config/__init__.py index b69f31d8..8e317575 100644 --- a/app/config/__init__.py +++ b/app/config/__init__.py @@ -24,7 +24,8 @@ # If the file wasn't renamed, we need to import it differently # Add parent to path temporarily to import the shadowed config.py import importlib.util - config_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.py') + + config_py_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.py") if os.path.exists(config_py_path): spec = importlib.util.spec_from_file_location("flask_config_module", config_py_path) flask_config = importlib.util.module_from_spec(spec) @@ -37,16 +38,16 @@ # Fallback - create minimal config class Config: pass + ProductionConfig = Config DevelopmentConfig = Config TestingConfig = Config __all__ = [ - 'get_analytics_config', - 'has_analytics_configured', - 'Config', - 'ProductionConfig', - 'DevelopmentConfig', - 'TestingConfig' + "get_analytics_config", + "has_analytics_configured", + "Config", + "ProductionConfig", + "DevelopmentConfig", + "TestingConfig", ] - diff --git a/app/config/analytics_defaults.py b/app/config/analytics_defaults.py index 7fdeae48..8d4b1931 100644 --- a/app/config/analytics_defaults.py +++ b/app/config/analytics_defaults.py @@ -28,37 +28,38 @@ # All builds have analytics configured, but telemetry is OPT-IN TELE_ENABLED_DEFAULT = "false" # Disabled by default for privacy + def _get_version_from_setup(): """ Get the application version from setup.py. - + setup.py is the SINGLE SOURCE OF TRUTH for version information. This function reads setup.py at runtime to get the current version. All other code should reference this function, not define versions themselves. - + Returns: str: Application version (e.g., "3.1.0") or "unknown" if setup.py can't be read """ import os import re - + try: # Get path to setup.py (root of project) - setup_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), 'setup.py') - + setup_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "setup.py") + # Read setup.py - with open(setup_path, 'r', encoding='utf-8') as f: + with open(setup_path, "r", encoding="utf-8") as f: content = f.read() - + # Extract version using regex # Matches: version='X.Y.Z' or version="X.Y.Z" version_match = re.search(r'version\s*=\s*[\'"]([^\'"]+)[\'"]', content) - + if version_match: return version_match.group(1) except Exception: pass - + # Fallback version if setup.py can't be read # This is the ONLY place besides setup.py where version is defined return "unknown" @@ -67,34 +68,35 @@ def _get_version_from_setup(): def get_analytics_config(): """ Get analytics configuration. - + Analytics keys are embedded at build time and cannot be overridden to ensure consistent telemetry collection across all installations. - + However, users maintain full control: - Telemetry is OPT-IN (disabled by default) - Can be disabled anytime in admin dashboard - No PII is ever collected - + Returns: dict: Analytics configuration """ + # Helper to check if a value is a placeholder (not replaced by GitHub Actions) def is_placeholder(value): return value.startswith("%%") and value.endswith("%%") - + # PostHog configuration - use embedded keys (no override) posthog_api_key = POSTHOG_API_KEY_DEFAULT if not is_placeholder(POSTHOG_API_KEY_DEFAULT) else "" - + # Sentry configuration - use embedded keys (no override) sentry_dsn = SENTRY_DSN_DEFAULT if not is_placeholder(SENTRY_DSN_DEFAULT) else "" - + # App version - read from setup.py at runtime app_version = _get_version_from_setup() - + # Note: Environment variables are NOT checked for keys to prevent override # Users control telemetry via the opt-in/opt-out toggle in admin dashboard - + return { "posthog_api_key": posthog_api_key, "posthog_host": POSTHOG_HOST_DEFAULT, # Fixed host, no override @@ -108,12 +110,13 @@ def is_placeholder(value): def has_analytics_configured(): """ Check if analytics keys are configured (embedded at build time). - + Returns: bool: True if analytics keys are embedded """ + def is_placeholder(value): return value.startswith("%%") and value.endswith("%%") - + # Check if keys have been replaced during build return not is_placeholder(POSTHOG_API_KEY_DEFAULT) diff --git a/app/constants.py b/app/constants.py index 193b3e81..1470758a 100644 --- a/app/constants.py +++ b/app/constants.py @@ -8,6 +8,7 @@ class TimeEntryStatus(Enum): """Status of a time entry""" + RUNNING = "running" PAUSED = "paused" STOPPED = "stopped" @@ -16,6 +17,7 @@ class TimeEntryStatus(Enum): class TimeEntrySource(Enum): """Source of a time entry""" + MANUAL = "manual" AUTO = "auto" API = "api" @@ -25,6 +27,7 @@ class TimeEntrySource(Enum): class ProjectStatus(Enum): """Project status values""" + ACTIVE = "active" INACTIVE = "inactive" ARCHIVED = "archived" @@ -32,6 +35,7 @@ class ProjectStatus(Enum): class InvoiceStatus(Enum): """Invoice status values""" + DRAFT = "draft" SENT = "sent" PAID = "paid" @@ -44,6 +48,7 @@ class InvoiceStatus(Enum): class PaymentStatus(Enum): """Payment status values""" + UNPAID = "unpaid" PARTIALLY_PAID = "partially_paid" FULLY_PAID = "fully_paid" @@ -52,6 +57,7 @@ class PaymentStatus(Enum): class TaskStatus(Enum): """Task status values""" + TODO = "todo" IN_PROGRESS = "in_progress" REVIEW = "review" @@ -61,6 +67,7 @@ class TaskStatus(Enum): class UserRole(Enum): """User role values""" + ADMIN = "admin" MANAGER = "manager" USER = "user" @@ -69,6 +76,7 @@ class UserRole(Enum): class BillableStatus(Enum): """Billable status""" + BILLABLE = True NON_BILLABLE = False @@ -88,8 +96,8 @@ class BillableStatus(Enum): # File upload limits MAX_FILE_SIZE = 16 * 1024 * 1024 # 16MB -ALLOWED_IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} -ALLOWED_DOCUMENT_EXTENSIONS = {'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt'} +ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"} +ALLOWED_DOCUMENT_EXTENSIONS = {".pdf", ".doc", ".docx", ".xls", ".xlsx", ".txt"} # Session and cookie defaults DEFAULT_SESSION_LIFETIME = 86400 # 24 hours in seconds @@ -101,8 +109,22 @@ class BillableStatus(Enum): # Currency codes (ISO 4217) SUPPORTED_CURRENCIES = [ - 'USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'CNY', - 'SEK', 'NOK', 'DKK', 'PLN', 'BRL', 'INR', 'ZAR', 'MXN' + "USD", + "EUR", + "GBP", + "JPY", + "AUD", + "CAD", + "CHF", + "CNY", + "SEK", + "NOK", + "DKK", + "PLN", + "BRL", + "INR", + "ZAR", + "MXN", ] # Date/time formats @@ -111,9 +133,11 @@ class BillableStatus(Enum): DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S" ISO_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S" + # Audit log action types class AuditAction(Enum): """Audit log action types""" + CREATE = "create" UPDATE = "update" DELETE = "delete" @@ -129,6 +153,7 @@ class AuditAction(Enum): # Webhook event types class WebhookEvent(Enum): """Webhook event types""" + TIME_ENTRY_CREATED = "time_entry.created" TIME_ENTRY_UPDATED = "time_entry.updated" TIME_ENTRY_DELETED = "time_entry.deleted" @@ -160,6 +185,7 @@ class WebhookEvent(Enum): # Notification types class NotificationType(Enum): """Notification types""" + INFO = "info" SUCCESS = "success" WARNING = "warning" @@ -169,6 +195,7 @@ class NotificationType(Enum): # Cache keys (for future Redis implementation) class CacheKey: """Cache key prefixes""" + USER = "user:" PROJECT = "project:" TIME_ENTRY = "time_entry:" @@ -176,4 +203,3 @@ class CacheKey: CLIENT = "client:" DASHBOARD = "dashboard:" REPORT = "report:" - diff --git a/app/integrations/__init__.py b/app/integrations/__init__.py index f35ddf4a..60912c73 100644 --- a/app/integrations/__init__.py +++ b/app/integrations/__init__.py @@ -4,5 +4,4 @@ from .base import BaseConnector -__all__ = ['BaseConnector'] - +__all__ = ["BaseConnector"] diff --git a/app/integrations/base.py b/app/integrations/base.py index 7e9cbf7a..e253a53c 100644 --- a/app/integrations/base.py +++ b/app/integrations/base.py @@ -10,7 +10,7 @@ class BaseConnector(ABC): """ Base class for all integration connectors. - + All connectors must implement these methods to provide a consistent interface for integration management. """ @@ -18,7 +18,7 @@ class BaseConnector(ABC): def __init__(self, integration, credentials): """ Initialize connector with integration and credentials. - + Args: integration: Integration model instance credentials: IntegrationCredential model instance @@ -42,11 +42,11 @@ def display_name(self) -> str: def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: """ Get OAuth authorization URL. - + Args: redirect_uri: OAuth callback URL state: Optional state parameter for CSRF protection - + Returns: Authorization URL """ @@ -56,11 +56,11 @@ def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: """ Exchange authorization code for access tokens. - + Args: code: Authorization code from OAuth callback redirect_uri: OAuth callback URL - + Returns: Dict with access_token, refresh_token, expires_at, etc. """ @@ -70,7 +70,7 @@ def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, An def refresh_access_token(self) -> Dict[str, Any]: """ Refresh access token using refresh token. - + Returns: Dict with new access_token, expires_at, etc. """ @@ -80,7 +80,7 @@ def refresh_access_token(self) -> Dict[str, Any]: def test_connection(self) -> Dict[str, Any]: """ Test the connection to the service. - + Returns: Dict with 'success' (bool) and 'message' (str) """ @@ -89,81 +89,68 @@ def test_connection(self) -> Dict[str, Any]: def get_access_token(self) -> Optional[str]: """ Get current access token, refreshing if needed. - + Returns: Access token string or None """ if not self.credentials: return None - + # Check if token needs refresh if self.credentials.needs_refresh(): try: new_tokens = self.refresh_access_token() - if new_tokens.get('access_token'): - return new_tokens['access_token'] + if new_tokens.get("access_token"): + return new_tokens["access_token"] except Exception: pass - + return self.credentials.access_token if self.credentials else None - def sync_data(self, sync_type: str = 'full') -> Dict[str, Any]: + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """ Sync data from the integrated service. - + Args: sync_type: Type of sync ('full', 'incremental', etc.) - + Returns: Dict with sync results """ # Default implementation - override in subclasses - return { - 'success': False, - 'message': 'Sync not implemented for this connector' - } + return {"success": False, "message": "Sync not implemented for this connector"} def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str]) -> Dict[str, Any]: """ Handle incoming webhook from the service. - + Args: payload: Webhook payload headers: Request headers - + Returns: Dict with processing results """ # Default implementation - override in subclasses - return { - 'success': False, - 'message': 'Webhook handling not implemented for this connector' - } + return {"success": False, "message": "Webhook handling not implemented for this connector"} def get_config_schema(self) -> Dict[str, Any]: """ Get configuration schema for this connector. - + Returns: Dict describing configuration fields """ - return { - 'fields': [], - 'required': [] - } + return {"fields": [], "required": []} def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """ Validate configuration. - + Args: config: Configuration dict to validate - + Returns: Dict with 'valid' (bool) and 'errors' (list) """ - return { - 'valid': True, - 'errors': [] - } - + return {"valid": True, "errors": []} diff --git a/app/integrations/github.py b/app/integrations/github.py index 15a7f71f..97b89b37 100644 --- a/app/integrations/github.py +++ b/app/integrations/github.py @@ -23,88 +23,77 @@ def provider_name(self) -> str: def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: """Get GitHub OAuth authorization URL.""" from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('github') - client_id = creds.get('client_id') or os.getenv('GITHUB_CLIENT_ID') + creds = settings.get_integration_credentials("github") + client_id = creds.get("client_id") or os.getenv("GITHUB_CLIENT_ID") if not client_id: raise ValueError("GITHUB_CLIENT_ID not configured") - - scopes = [ - 'repo', - 'issues:read', - 'issues:write', - 'user:email' - ] - + + scopes = ["repo", "issues:read", "issues:write", "user:email"] + auth_url = "https://github.com/login/oauth/authorize" - params = { - 'client_id': client_id, - 'redirect_uri': redirect_uri, - 'scope': ' '.join(scopes), - 'state': state or '' - } - - query_string = '&'.join([f"{k}={v}" for k, v in params.items()]) + params = {"client_id": client_id, "redirect_uri": redirect_uri, "scope": " ".join(scopes), "state": state or ""} + + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) return f"{auth_url}?{query_string}" def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: """Exchange authorization code for tokens.""" from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('github') - client_id = creds.get('client_id') or os.getenv('GITHUB_CLIENT_ID') - client_secret = creds.get('client_secret') or os.getenv('GITHUB_CLIENT_SECRET') - + creds = settings.get_integration_credentials("github") + client_id = creds.get("client_id") or os.getenv("GITHUB_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("GITHUB_CLIENT_SECRET") + if not client_id or not client_secret: raise ValueError("GitHub OAuth credentials not configured") - + token_url = "https://github.com/login/oauth/access_token" - - response = requests.post(token_url, data={ - 'client_id': client_id, - 'client_secret': client_secret, - 'code': code, - 'redirect_uri': redirect_uri - }, headers={ - 'Accept': 'application/json' - }) - + + response = requests.post( + token_url, + data={"client_id": client_id, "client_secret": client_secret, "code": code, "redirect_uri": redirect_uri}, + headers={"Accept": "application/json"}, + ) + response.raise_for_status() data = response.json() - - if 'error' in data: + + if "error" in data: raise ValueError(f"GitHub OAuth error: {data.get('error_description', data.get('error'))}") - + # GitHub tokens don't expire by default, but can be configured expires_at = None - if 'expires_in' in data: - expires_at = datetime.utcnow() + timedelta(seconds=data['expires_in']) - + if "expires_in" in data: + expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"]) + # Get user info - access_token = data.get('access_token') + access_token = data.get("access_token") user_info = {} if access_token: try: - user_response = requests.get('https://api.github.com/user', headers={ - 'Authorization': f'token {access_token}', - 'Accept': 'application/vnd.github.v3+json' - }) + user_response = requests.get( + "https://api.github.com/user", + headers={"Authorization": f"token {access_token}", "Accept": "application/vnd.github.v3+json"}, + ) if user_response.status_code == 200: user_info = user_response.json() except Exception: pass - + return { - 'access_token': access_token, - 'refresh_token': data.get('refresh_token'), # GitHub doesn't provide refresh tokens by default - 'expires_at': expires_at, - 'token_type': data.get('token_type', 'Bearer'), - 'scope': data.get('scope'), - 'extra_data': { - 'user_login': user_info.get('login'), - 'user_name': user_info.get('name'), - 'user_email': user_info.get('email') - } + "access_token": access_token, + "refresh_token": data.get("refresh_token"), # GitHub doesn't provide refresh tokens by default + "expires_at": expires_at, + "token_type": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + "extra_data": { + "user_login": user_info.get("login"), + "user_name": user_info.get("name"), + "user_email": user_info.get("email"), + }, } def refresh_access_token(self) -> Dict[str, Any]: @@ -113,80 +102,59 @@ def refresh_access_token(self) -> Dict[str, Any]: # If using GitHub Apps, refresh would be handled differently if not self.credentials or not self.credentials.access_token: raise ValueError("No access token available") - + # For now, just return the existing token # In production, implement proper refresh if using GitHub Apps return { - 'access_token': self.credentials.access_token, - 'refresh_token': self.credentials.refresh_token, - 'expires_at': self.credentials.expires_at + "access_token": self.credentials.access_token, + "refresh_token": self.credentials.refresh_token, + "expires_at": self.credentials.expires_at, } def test_connection(self) -> Dict[str, Any]: """Test connection to GitHub.""" token = self.get_access_token() if not token: - return { - 'success': False, - 'message': 'No access token available' - } - + return {"success": False, "message": "No access token available"} + api_url = "https://api.github.com/user" - + try: - response = requests.get(api_url, headers={ - 'Authorization': f'token {token}', - 'Accept': 'application/vnd.github.v3+json' - }) - + response = requests.get( + api_url, headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"} + ) + if response.status_code == 200: user_data = response.json() - return { - 'success': True, - 'message': f"Connected as {user_data.get('login', 'Unknown')}" - } + return {"success": True, "message": f"Connected as {user_data.get('login', 'Unknown')}"} else: - return { - 'success': False, - 'message': f"API returned status {response.status_code}" - } + return {"success": False, "message": f"API returned status {response.status_code}"} except Exception as e: - return { - 'success': False, - 'message': f"Connection error: {str(e)}" - } + return {"success": False, "message": f"Connection error: {str(e)}"} - def sync_data(self, sync_type: str = 'full') -> Dict[str, Any]: + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync issues from GitHub repositories.""" token = self.get_access_token() if not token: - return { - 'success': False, - 'message': 'No access token available' - } - + return {"success": False, "message": "No access token available"} + # This would sync GitHub issues and create time entries # Implementation depends on specific requirements - - return { - 'success': True, - 'message': 'Sync completed', - 'synced_items': 0 - } + + return {"success": True, "message": "Sync completed", "synced_items": 0} def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" return { - 'fields': [ + "fields": [ { - 'name': 'repositories', - 'label': 'Repositories', - 'type': 'text', - 'required': False, - 'placeholder': 'owner/repo1, owner/repo2', - 'help': 'Comma-separated list of repositories to sync' + "name": "repositories", + "label": "Repositories", + "type": "text", + "required": False, + "placeholder": "owner/repo1, owner/repo2", + "help": "Comma-separated list of repositories to sync", } ], - 'required': [] + "required": [], } - diff --git a/app/integrations/jira.py b/app/integrations/jira.py index b5ff870b..e13955a8 100644 --- a/app/integrations/jira.py +++ b/app/integrations/jira.py @@ -24,167 +24,150 @@ def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: """Get Jira OAuth authorization URL.""" # Jira uses OAuth 2.0 from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('jira') - client_id = creds.get('client_id') or os.getenv('JIRA_CLIENT_ID') + creds = settings.get_integration_credentials("jira") + client_id = creds.get("client_id") or os.getenv("JIRA_CLIENT_ID") if not client_id: raise ValueError("JIRA_CLIENT_ID not configured") - - base_url = self.integration.config.get('jira_url', 'https://your-domain.atlassian.net') + + base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net") auth_url = f"{base_url}/plugins/servlet/oauth/authorize" - + params = { - 'client_id': client_id, - 'redirect_uri': redirect_uri, - 'response_type': 'code', - 'scope': 'read:jira-work write:jira-work offline_access', - 'state': state or '' + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": "read:jira-work write:jira-work offline_access", + "state": state or "", } - - query_string = '&'.join([f"{k}={v}" for k, v in params.items()]) + + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) return f"{auth_url}?{query_string}" def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: """Exchange authorization code for tokens.""" from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('jira') - client_id = creds.get('client_id') or os.getenv('JIRA_CLIENT_ID') - client_secret = creds.get('client_secret') or os.getenv('JIRA_CLIENT_SECRET') - + creds = settings.get_integration_credentials("jira") + client_id = creds.get("client_id") or os.getenv("JIRA_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("JIRA_CLIENT_SECRET") + if not client_id or not client_secret: raise ValueError("Jira OAuth credentials not configured") - - base_url = self.integration.config.get('jira_url', 'https://your-domain.atlassian.net') + + base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net") token_url = f"{base_url}/plugins/servlet/oauth/token" - - response = requests.post(token_url, data={ - 'grant_type': 'authorization_code', - 'client_id': client_id, - 'client_secret': client_secret, - 'code': code, - 'redirect_uri': redirect_uri - }) - + + response = requests.post( + token_url, + data={ + "grant_type": "authorization_code", + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + }, + ) + response.raise_for_status() data = response.json() - + expires_at = None - if 'expires_in' in data: - expires_at = datetime.utcnow() + timedelta(seconds=data['expires_in']) - + if "expires_in" in data: + expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"]) + return { - 'access_token': data.get('access_token'), - 'refresh_token': data.get('refresh_token'), - 'expires_at': expires_at, - 'token_type': data.get('token_type', 'Bearer'), - 'scope': data.get('scope'), - 'extra_data': { - 'cloud_id': data.get('cloud_id'), - 'site_url': base_url - } + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at, + "token_type": data.get("token_type", "Bearer"), + "scope": data.get("scope"), + "extra_data": {"cloud_id": data.get("cloud_id"), "site_url": base_url}, } def refresh_access_token(self) -> Dict[str, Any]: """Refresh access token.""" if not self.credentials or not self.credentials.refresh_token: raise ValueError("No refresh token available") - + from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('jira') - client_id = creds.get('client_id') or os.getenv('JIRA_CLIENT_ID') - client_secret = creds.get('client_secret') or os.getenv('JIRA_CLIENT_SECRET') - - base_url = self.integration.config.get('jira_url', 'https://your-domain.atlassian.net') + creds = settings.get_integration_credentials("jira") + client_id = creds.get("client_id") or os.getenv("JIRA_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("JIRA_CLIENT_SECRET") + + base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net") token_url = f"{base_url}/plugins/servlet/oauth/token" - - response = requests.post(token_url, data={ - 'grant_type': 'refresh_token', - 'client_id': client_id, - 'client_secret': client_secret, - 'refresh_token': self.credentials.refresh_token - }) - + + response = requests.post( + token_url, + data={ + "grant_type": "refresh_token", + "client_id": client_id, + "client_secret": client_secret, + "refresh_token": self.credentials.refresh_token, + }, + ) + response.raise_for_status() data = response.json() - + expires_at = None - if 'expires_in' in data: - expires_at = datetime.utcnow() + timedelta(seconds=data['expires_in']) - + if "expires_in" in data: + expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"]) + return { - 'access_token': data.get('access_token'), - 'refresh_token': data.get('refresh_token', self.credentials.refresh_token), - 'expires_at': expires_at + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token", self.credentials.refresh_token), + "expires_at": expires_at, } def test_connection(self) -> Dict[str, Any]: """Test connection to Jira.""" token = self.get_access_token() if not token: - return { - 'success': False, - 'message': 'No access token available' - } - - base_url = self.integration.config.get('jira_url', 'https://your-domain.atlassian.net') + return {"success": False, "message": "No access token available"} + + base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net") api_url = f"{base_url}/rest/api/3/myself" - + try: - response = requests.get(api_url, headers={ - 'Authorization': f'Bearer {token}', - 'Accept': 'application/json' - }) - + response = requests.get(api_url, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}) + if response.status_code == 200: user_data = response.json() - return { - 'success': True, - 'message': f"Connected as {user_data.get('displayName', 'Unknown')}" - } + return {"success": True, "message": f"Connected as {user_data.get('displayName', 'Unknown')}"} else: - return { - 'success': False, - 'message': f"API returned status {response.status_code}" - } + return {"success": False, "message": f"API returned status {response.status_code}"} except Exception as e: - return { - 'success': False, - 'message': f"Connection error: {str(e)}" - } + return {"success": False, "message": f"Connection error: {str(e)}"} - def sync_data(self, sync_type: str = 'full') -> Dict[str, Any]: + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync issues from Jira.""" token = self.get_access_token() if not token: - return { - 'success': False, - 'message': 'No access token available' - } - - base_url = self.integration.config.get('jira_url', 'https://your-domain.atlassian.net') + return {"success": False, "message": "No access token available"} + + base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net") # This would sync issues and create time entries # Implementation depends on specific requirements - - return { - 'success': True, - 'message': 'Sync completed', - 'synced_items': 0 - } + + return {"success": True, "message": "Sync completed", "synced_items": 0} def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" return { - 'fields': [ + "fields": [ { - 'name': 'jira_url', - 'label': 'Jira URL', - 'type': 'url', - 'required': True, - 'placeholder': 'https://your-domain.atlassian.net' + "name": "jira_url", + "label": "Jira URL", + "type": "url", + "required": True, + "placeholder": "https://your-domain.atlassian.net", } ], - 'required': ['jira_url'] + "required": ["jira_url"], } - diff --git a/app/integrations/registry.py b/app/integrations/registry.py index 482e36a4..a5506ee7 100644 --- a/app/integrations/registry.py +++ b/app/integrations/registry.py @@ -11,11 +11,10 @@ def register_connectors(): """Register all available connectors.""" - IntegrationService.register_connector('jira', JiraConnector) - IntegrationService.register_connector('slack', SlackConnector) - IntegrationService.register_connector('github', GitHubConnector) + IntegrationService.register_connector("jira", JiraConnector) + IntegrationService.register_connector("slack", SlackConnector) + IntegrationService.register_connector("github", GitHubConnector) # Auto-register on import register_connectors() - diff --git a/app/integrations/slack.py b/app/integrations/slack.py index 7c12ae69..326fa97d 100644 --- a/app/integrations/slack.py +++ b/app/integrations/slack.py @@ -23,195 +23,155 @@ def provider_name(self) -> str: def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: """Get Slack OAuth authorization URL.""" from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('slack') - client_id = creds.get('client_id') or os.getenv('SLACK_CLIENT_ID') + creds = settings.get_integration_credentials("slack") + client_id = creds.get("client_id") or os.getenv("SLACK_CLIENT_ID") if not client_id: raise ValueError("SLACK_CLIENT_ID not configured") - - scopes = [ - 'chat:write', - 'chat:write.public', - 'users:read', - 'channels:read', - 'groups:read' - ] - + + scopes = ["chat:write", "chat:write.public", "users:read", "channels:read", "groups:read"] + auth_url = "https://slack.com/oauth/v2/authorize" - params = { - 'client_id': client_id, - 'redirect_uri': redirect_uri, - 'scope': ','.join(scopes), - 'state': state or '' - } - - query_string = '&'.join([f"{k}={v}" for k, v in params.items()]) + params = {"client_id": client_id, "redirect_uri": redirect_uri, "scope": ",".join(scopes), "state": state or ""} + + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) return f"{auth_url}?{query_string}" def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]: """Exchange authorization code for tokens.""" from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('slack') - client_id = creds.get('client_id') or os.getenv('SLACK_CLIENT_ID') - client_secret = creds.get('client_secret') or os.getenv('SLACK_CLIENT_SECRET') - + creds = settings.get_integration_credentials("slack") + client_id = creds.get("client_id") or os.getenv("SLACK_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("SLACK_CLIENT_SECRET") + if not client_id or not client_secret: raise ValueError("Slack OAuth credentials not configured") - + token_url = "https://slack.com/api/oauth.v2.access" - - response = requests.post(token_url, data={ - 'client_id': client_id, - 'client_secret': client_secret, - 'code': code, - 'redirect_uri': redirect_uri - }) - + + response = requests.post( + token_url, + data={"client_id": client_id, "client_secret": client_secret, "code": code, "redirect_uri": redirect_uri}, + ) + response.raise_for_status() data = response.json() - - if not data.get('ok'): + + if not data.get("ok"): raise ValueError(f"Slack API error: {data.get('error', 'Unknown error')}") - - access_token = data.get('access_token') - expires_in = data.get('expires_in', 0) + + access_token = data.get("access_token") + expires_in = data.get("expires_in", 0) expires_at = None if expires_in > 0: expires_at = datetime.utcnow() + timedelta(seconds=expires_in) - + return { - 'access_token': access_token, - 'refresh_token': data.get('refresh_token'), - 'expires_at': expires_at, - 'token_type': 'Bearer', - 'scope': data.get('scope'), - 'extra_data': { - 'team_id': data.get('team', {}).get('id'), - 'team_name': data.get('team', {}).get('name'), - 'authed_user': data.get('authed_user', {}) - } + "access_token": access_token, + "refresh_token": data.get("refresh_token"), + "expires_at": expires_at, + "token_type": "Bearer", + "scope": data.get("scope"), + "extra_data": { + "team_id": data.get("team", {}).get("id"), + "team_name": data.get("team", {}).get("name"), + "authed_user": data.get("authed_user", {}), + }, } def refresh_access_token(self) -> Dict[str, Any]: """Refresh access token.""" if not self.credentials or not self.credentials.refresh_token: raise ValueError("No refresh token available") - + from app.models import Settings + settings = Settings.get_settings() - creds = settings.get_integration_credentials('slack') - client_id = creds.get('client_id') or os.getenv('SLACK_CLIENT_ID') - client_secret = creds.get('client_secret') or os.getenv('SLACK_CLIENT_SECRET') - + creds = settings.get_integration_credentials("slack") + client_id = creds.get("client_id") or os.getenv("SLACK_CLIENT_ID") + client_secret = creds.get("client_secret") or os.getenv("SLACK_CLIENT_SECRET") + token_url = "https://slack.com/api/oauth.v2.access" - - response = requests.post(token_url, data={ - 'client_id': client_id, - 'client_secret': client_secret, - 'grant_type': 'refresh_token', - 'refresh_token': self.credentials.refresh_token - }) - + + response = requests.post( + token_url, + data={ + "client_id": client_id, + "client_secret": client_secret, + "grant_type": "refresh_token", + "refresh_token": self.credentials.refresh_token, + }, + ) + response.raise_for_status() data = response.json() - - if not data.get('ok'): + + if not data.get("ok"): raise ValueError(f"Slack API error: {data.get('error', 'Unknown error')}") - + expires_at = None - if 'expires_in' in data: - expires_at = datetime.utcnow() + timedelta(seconds=data['expires_in']) - + if "expires_in" in data: + expires_at = datetime.utcnow() + timedelta(seconds=data["expires_in"]) + return { - 'access_token': data.get('access_token'), - 'refresh_token': data.get('refresh_token', self.credentials.refresh_token), - 'expires_at': expires_at + "access_token": data.get("access_token"), + "refresh_token": data.get("refresh_token", self.credentials.refresh_token), + "expires_at": expires_at, } def test_connection(self) -> Dict[str, Any]: """Test connection to Slack.""" token = self.get_access_token() if not token: - return { - 'success': False, - 'message': 'No access token available' - } - + return {"success": False, "message": "No access token available"} + api_url = "https://slack.com/api/auth.test" - + try: - response = requests.post(api_url, headers={ - 'Authorization': f'Bearer {token}' - }) - + response = requests.post(api_url, headers={"Authorization": f"Bearer {token}"}) + response.raise_for_status() data = response.json() - - if data.get('ok'): - return { - 'success': True, - 'message': f"Connected to {data.get('team', 'Unknown Team')}" - } + + if data.get("ok"): + return {"success": True, "message": f"Connected to {data.get('team', 'Unknown Team')}"} else: - return { - 'success': False, - 'message': f"Slack API error: {data.get('error', 'Unknown error')}" - } + return {"success": False, "message": f"Slack API error: {data.get('error', 'Unknown error')}"} except Exception as e: - return { - 'success': False, - 'message': f"Connection error: {str(e)}" - } + return {"success": False, "message": f"Connection error: {str(e)}"} - def sync_data(self, sync_type: str = 'full') -> Dict[str, Any]: + def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync data from Slack (channels, users, etc.).""" token = self.get_access_token() if not token: - return { - 'success': False, - 'message': 'No access token available' - } - + return {"success": False, "message": "No access token available"} + # This would sync Slack channels, users, etc. # Implementation depends on specific requirements - - return { - 'success': True, - 'message': 'Sync completed', - 'synced_items': 0 - } + + return {"success": True, "message": "Sync completed", "synced_items": 0} def send_message(self, channel: str, text: str) -> Dict[str, Any]: """Send a message to a Slack channel.""" token = self.get_access_token() if not token: - return { - 'success': False, - 'message': 'No access token available' - } - + return {"success": False, "message": "No access token available"} + api_url = "https://slack.com/api/chat.postMessage" - - response = requests.post(api_url, headers={ - 'Authorization': f'Bearer {token}', - 'Content-Type': 'application/json' - }, json={ - 'channel': channel, - 'text': text - }) - + + response = requests.post( + api_url, + headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"}, + json={"channel": channel, "text": text}, + ) + response.raise_for_status() data = response.json() - - if data.get('ok'): - return { - 'success': True, - 'message': 'Message sent successfully' - } - else: - return { - 'success': False, - 'message': f"Slack API error: {data.get('error', 'Unknown error')}" - } + if data.get("ok"): + return {"success": True, "message": "Message sent successfully"} + else: + return {"success": False, "message": f"Slack API error: {data.get('error', 'Unknown error')}"} diff --git a/app/models/activity.py b/app/models/activity.py index 1bc7eb74..1fcb57c0 100644 --- a/app/models/activity.py +++ b/app/models/activity.py @@ -4,48 +4,64 @@ class Activity(db.Model): """Activity log for tracking user actions across the system - + Provides a comprehensive audit trail and activity feed showing what users are doing in the application. """ - - __tablename__ = 'activities' - + + __tablename__ = "activities" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Action details - action = db.Column(db.String(50), nullable=False, index=True) # 'created', 'updated', 'deleted', 'started', 'stopped', etc. - entity_type = db.Column(db.String(50), nullable=False, index=True) # 'project', 'task', 'time_entry', 'invoice', 'client' + action = db.Column( + db.String(50), nullable=False, index=True + ) # 'created', 'updated', 'deleted', 'started', 'stopped', etc. + entity_type = db.Column( + db.String(50), nullable=False, index=True + ) # 'project', 'task', 'time_entry', 'invoice', 'client' entity_id = db.Column(db.Integer, nullable=False, index=True) entity_name = db.Column(db.String(500), nullable=True) # Cached name for display - + # Description and extra data description = db.Column(db.Text, nullable=True) # Human-readable description extra_data = db.Column(db.JSON, nullable=True) # Additional context (changes, values, etc.) - + # IP and user agent for security audit ip_address = db.Column(db.String(45), nullable=True) user_agent = db.Column(db.Text, nullable=True) - + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) - + # Relationships - user = db.relationship('User', backref='activities') - + user = db.relationship("User", backref="activities") + # Indexes for common queries __table_args__ = ( - db.Index('ix_activities_user_created', 'user_id', 'created_at'), - db.Index('ix_activities_entity', 'entity_type', 'entity_id'), + db.Index("ix_activities_user_created", "user_id", "created_at"), + db.Index("ix_activities_entity", "entity_type", "entity_id"), ) - + def __repr__(self): return f'' - + @classmethod - def log(cls, user_id, action, entity_type, entity_id, entity_name=None, description=None, extra_data=None, metadata=None, ip_address=None, user_agent=None): + def log( + cls, + user_id, + action, + entity_type, + entity_id, + entity_name=None, + description=None, + extra_data=None, + metadata=None, + ip_address=None, + user_agent=None, + ): """Convenience method to log an activity - + Usage: Activity.log( user_id=current_user.id, @@ -55,12 +71,12 @@ def log(cls, user_id, action, entity_type, entity_id, entity_name=None, descript entity_name=project.name, description=f'Created project "{project.name}"' ) - + Note: 'metadata' parameter is deprecated, use 'extra_data' instead. """ # Support both parameter names for backward compatibility data = extra_data if extra_data is not None else metadata - + activity = cls( user_id=user_id, action=action, @@ -70,106 +86,107 @@ def log(cls, user_id, action, entity_type, entity_id, entity_name=None, descript description=description, extra_data=data, ip_address=ip_address, - user_agent=user_agent + user_agent=user_agent, ) 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 - }) + + 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}") - + # Trigger webhooks for this activity try: from app.utils.webhook_dispatcher import WebhookDispatcher + WebhookDispatcher.on_activity_logged(activity) except Exception as webhook_error: # Don't let webhook errors break activity logging import logging + logger = logging.getLogger(__name__) logger.warning(f"Failed to dispatch webhook for activity: {webhook_error}") except Exception as e: db.session.rollback() # Don't let activity logging break the main flow 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): """Get recent activities - + Args: user_id: Filter by user (None for all users) limit: Maximum number of activities to return entity_type: Filter by entity type """ query = cls.query - + if user_id: query = query.filter_by(user_id=user_id) - + if entity_type: query = query.filter_by(entity_type=entity_type) - + return query.order_by(cls.created_at.desc()).limit(limit).all() - + def to_dict(self): """Convert to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'username': self.user.username if self.user else None, - 'display_name': self.user.display_name if self.user else None, - 'action': self.action, - 'entity_type': self.entity_type, - 'entity_id': self.entity_id, - 'entity_name': self.entity_name, - 'description': self.description, - 'extra_data': self.extra_data, - 'metadata': self.extra_data, # For backward compatibility - 'created_at': self.created_at.isoformat() if self.created_at else None, + "id": self.id, + "user_id": self.user_id, + "username": self.user.username if self.user else None, + "display_name": self.user.display_name if self.user else None, + "action": self.action, + "entity_type": self.entity_type, + "entity_id": self.entity_id, + "entity_name": self.entity_name, + "description": self.description, + "extra_data": self.extra_data, + "metadata": self.extra_data, # For backward compatibility + "created_at": self.created_at.isoformat() if self.created_at else None, } - + def get_icon(self): """Get icon class for this activity type""" icons = { - 'created': 'fas fa-plus-circle text-green-500', - 'updated': 'fas fa-edit text-blue-500', - 'deleted': 'fas fa-trash text-red-500', - 'started': 'fas fa-play text-green-500', - 'stopped': 'fas fa-stop text-red-500', - 'completed': 'fas fa-check-circle text-green-500', - 'assigned': 'fas fa-user-plus text-blue-500', - 'commented': 'fas fa-comment text-gray-500', - 'sent': 'fas fa-paper-plane text-blue-500', - 'paid': 'fas fa-dollar-sign text-green-500', + "created": "fas fa-plus-circle text-green-500", + "updated": "fas fa-edit text-blue-500", + "deleted": "fas fa-trash text-red-500", + "started": "fas fa-play text-green-500", + "stopped": "fas fa-stop text-red-500", + "completed": "fas fa-check-circle text-green-500", + "assigned": "fas fa-user-plus text-blue-500", + "commented": "fas fa-comment text-gray-500", + "sent": "fas fa-paper-plane text-blue-500", + "paid": "fas fa-dollar-sign text-green-500", } - return icons.get(self.action, 'fas fa-circle text-gray-500') - + return icons.get(self.action, "fas fa-circle text-gray-500") + def get_color(self): """Get color class for this activity type""" colors = { - 'created': 'green', - 'updated': 'blue', - 'deleted': 'red', - 'started': 'green', - 'stopped': 'red', - 'completed': 'green', - 'assigned': 'blue', - 'commented': 'gray', - 'sent': 'blue', - 'paid': 'green', + "created": "green", + "updated": "blue", + "deleted": "red", + "started": "green", + "stopped": "red", + "completed": "green", + "assigned": "blue", + "commented": "gray", + "sent": "blue", + "paid": "green", } - return colors.get(self.action, 'gray') - + return colors.get(self.action, "gray") diff --git a/app/models/api_token.py b/app/models/api_token.py index 11bd7661..49db7e4c 100644 --- a/app/models/api_token.py +++ b/app/models/api_token.py @@ -1,4 +1,5 @@ """API Token model for REST API authentication""" + import secrets from datetime import datetime, timedelta from app import db @@ -7,73 +8,74 @@ class ApiToken(db.Model): """API Token for authenticating REST API requests""" - - __tablename__ = 'api_tokens' - + + __tablename__ = "api_tokens" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) description = db.Column(db.Text) token_hash = db.Column(db.String(128), unique=True, nullable=False, index=True) token_prefix = db.Column(db.String(10), nullable=False) # First 8 chars for identification - + # Ownership and permissions - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - user = relationship('User', backref='api_tokens') - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + user = relationship("User", backref="api_tokens") + # Scopes for fine-grained permissions (comma-separated) # Examples: read:projects, write:time_entries, admin:all - scopes = db.Column(db.Text, default='') - + scopes = db.Column(db.Text, default="") + # Token lifecycle created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) expires_at = db.Column(db.DateTime) last_used_at = db.Column(db.DateTime) is_active = db.Column(db.Boolean, default=True, nullable=False) - + # IP restrictions (comma-separated list of allowed IPs/CIDR blocks) ip_whitelist = db.Column(db.Text) - + # Usage tracking usage_count = db.Column(db.Integer, default=0, nullable=False) - + def __repr__(self): - return f'' - + return f"" + @staticmethod def generate_token(): """Generate a new secure random token""" # Format: tt_<32 random chars> random_part = secrets.token_urlsafe(32)[:32] return f"tt_{random_part}" - + @staticmethod def hash_token(token): """Hash a token for storage""" import hashlib + return hashlib.sha256(token.encode()).hexdigest() - + @classmethod - def create_token(cls, user_id, name, description='', scopes='', expires_days=None): + def create_token(cls, user_id, name, description="", scopes="", expires_days=None): """Create a new API token - + Args: user_id: User ID who owns this token name: Human-readable name for the token description: Optional description scopes: Comma-separated list of scopes expires_days: Number of days until expiration (None = never expires) - + Returns: tuple: (ApiToken instance, plain_token) """ plain_token = cls.generate_token() token_hash = cls.hash_token(plain_token) token_prefix = plain_token[:8] - + expires_at = None if expires_days: expires_at = datetime.utcnow() + timedelta(days=expires_days) - + api_token = cls( name=name, description=description, @@ -81,15 +83,15 @@ def create_token(cls, user_id, name, description='', scopes='', expires_days=Non token_prefix=token_prefix, user_id=user_id, scopes=scopes, - expires_at=expires_at + expires_at=expires_at, ) - + return api_token, plain_token - + def verify_token(self, plain_token): """Verify if the provided token matches this record""" return self.token_hash == self.hash_token(plain_token) - + def is_valid(self): """Check if token is valid (active and not expired)""" if not self.is_active: @@ -97,57 +99,56 @@ def is_valid(self): if self.expires_at and self.expires_at < datetime.utcnow(): return False return True - + def has_scope(self, required_scope): """Check if token has a specific scope - + Args: required_scope: The scope to check (e.g., 'read:projects') - + Returns: bool: True if token has the scope """ if not self.scopes: return False - - token_scopes = [s.strip() for s in self.scopes.split(',')] - + + token_scopes = [s.strip() for s in self.scopes.split(",")] + # Check for wildcard admin scope - if 'admin:all' in token_scopes or '*' in token_scopes: + if "admin:all" in token_scopes or "*" in token_scopes: return True - + # Check for exact match if required_scope in token_scopes: return True - + # Check for wildcard resource scope (e.g., read:* matches read:projects) - resource_type = required_scope.split(':')[0] if ':' in required_scope else None + resource_type = required_scope.split(":")[0] if ":" in required_scope else None if resource_type and f"{resource_type}:*" in token_scopes: return True - + return False - + def record_usage(self, ip_address=None): """Record token usage""" self.last_used_at = datetime.utcnow() self.usage_count += 1 db.session.commit() - + def to_dict(self, include_token=False): """Convert to dictionary for API responses""" data = { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'token_prefix': self.token_prefix, - 'scopes': self.scopes.split(',') if self.scopes else [], - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'expires_at': self.expires_at.isoformat() if self.expires_at else None, - 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, - 'is_active': self.is_active, - 'usage_count': self.usage_count, - 'user_id': self.user_id + "id": self.id, + "name": self.name, + "description": self.description, + "token_prefix": self.token_prefix, + "scopes": self.scopes.split(",") if self.scopes else [], + "created_at": self.created_at.isoformat() if self.created_at else None, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + "is_active": self.is_active, + "usage_count": self.usage_count, + "user_id": self.user_id, } - - return data + return data diff --git a/app/models/audit_log.py b/app/models/audit_log.py index ab6fe104..185ed7e0 100644 --- a/app/models/audit_log.py +++ b/app/models/audit_log.py @@ -6,7 +6,7 @@ class AuditLog(db.Model): """Audit log model for tracking detailed changes to entities - + Provides comprehensive audit trail tracking: - Who made the change (user_id) - What entity was changed (entity_type, entity_id) @@ -15,59 +15,76 @@ class AuditLog(db.Model): - Action type (created, updated, deleted) - Additional context (ip_address, user_agent, request_path) """ - - __tablename__ = 'audit_logs' - + + __tablename__ = "audit_logs" + id = db.Column(db.Integer, primary_key=True) - + # User who made the change - user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + # Entity being changed - entity_type = db.Column(db.String(50), nullable=False, index=True) # 'project', 'task', 'time_entry', 'invoice', 'client', 'user', etc. + entity_type = db.Column( + db.String(50), nullable=False, index=True + ) # 'project', 'task', 'time_entry', 'invoice', 'client', 'user', etc. entity_id = db.Column(db.Integer, nullable=False, index=True) entity_name = db.Column(db.String(500), nullable=True) # Cached name for display - + # Action details - action = db.Column(db.String(20), nullable=False) # 'created', 'updated', 'deleted' - index defined in __table_args__ - field_name = db.Column(db.String(100), nullable=True, index=True) # Name of the field that changed (None for create/delete) - + action = db.Column( + db.String(20), nullable=False + ) # 'created', 'updated', 'deleted' - index defined in __table_args__ + field_name = db.Column( + db.String(100), nullable=True, index=True + ) # Name of the field that changed (None for create/delete) + # Change values (stored as JSON for flexibility) old_value = db.Column(db.Text, nullable=True) # JSON-encoded old value new_value = db.Column(db.Text, nullable=True) # JSON-encoded new value - + # Human-readable change description change_description = 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) request_path = db.Column(db.String(500), nullable=True) - + # Timestamp created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) # index defined in __table_args__ - + # Relationships - user = db.relationship('User', backref='audit_logs') - + user = db.relationship("User", backref="audit_logs") + # Indexes for common queries __table_args__ = ( - db.Index('ix_audit_logs_entity', 'entity_type', 'entity_id'), - db.Index('ix_audit_logs_user_created', 'user_id', 'created_at'), - db.Index('ix_audit_logs_created_at', 'created_at'), - db.Index('ix_audit_logs_action', 'action'), + db.Index("ix_audit_logs_entity", "entity_type", "entity_id"), + db.Index("ix_audit_logs_user_created", "user_id", "created_at"), + db.Index("ix_audit_logs_created_at", "created_at"), + db.Index("ix_audit_logs_action", "action"), ) - + def __repr__(self): - return f'' - + return f"" + @classmethod - def log_change(cls, user_id, action, entity_type, entity_id, field_name=None, - old_value=None, new_value=None, entity_name=None, - change_description=None, ip_address=None, user_agent=None, - request_path=None): + def log_change( + cls, + user_id, + action, + entity_type, + entity_id, + field_name=None, + old_value=None, + new_value=None, + entity_name=None, + change_description=None, + ip_address=None, + user_agent=None, + request_path=None, + ): """Log a change to the audit trail - + Args: user_id: ID of the user making the change (None for system actions) action: 'created', 'updated', or 'deleted' @@ -85,7 +102,7 @@ def log_change(cls, user_id, action, entity_type, entity_id, field_name=None, # 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) - + audit_log = cls( user_id=user_id, action=action, @@ -98,9 +115,9 @@ def log_change(cls, user_id, action, entity_type, entity_id, field_name=None, change_description=change_description, ip_address=ip_address, user_agent=user_agent, - request_path=request_path + request_path=request_path, ) - + try: # Add to session - don't commit here as we're likely in the middle of a transaction # The main transaction will commit everything together @@ -119,110 +136,112 @@ def log_change(cls, user_id, action, entity_type, entity_id, field_name=None, # Use debug level to avoid cluttering logs with expected errors # (e.g., when audit_logs table doesn't exist yet) import logging + logger = logging.getLogger(__name__) logger.debug(f"Failed to log audit change (non-critical): {e}") - + @staticmethod def _encode_value(value): """Encode a value as JSON string, handling None and special types""" if value is None: return None - + # Handle datetime objects if isinstance(value, datetime): return value.isoformat() - + # Handle Decimal and other types that aren't JSON serializable try: return json.dumps(value, default=str) except (TypeError, ValueError): return str(value) - + @staticmethod def _decode_value(value_str): """Decode a JSON string back to a Python value""" if value_str is None: return None - + try: return json.loads(value_str) except (json.JSONDecodeError, TypeError): # If it's not valid JSON, return as string return value_str - + def get_old_value(self): """Get the decoded old value""" return self._decode_value(self.old_value) - + def get_new_value(self): """Get the decoded new value""" return self._decode_value(self.new_value) - + @classmethod def get_for_entity(cls, entity_type, entity_id, limit=100): """Get audit logs for a specific entity""" - return cls.query.filter_by( - entity_type=entity_type, - entity_id=entity_id - ).order_by(cls.created_at.desc()).limit(limit).all() - + return ( + cls.query.filter_by(entity_type=entity_type, entity_id=entity_id) + .order_by(cls.created_at.desc()) + .limit(limit) + .all() + ) + @classmethod def get_for_user(cls, user_id, limit=100): """Get audit logs for actions by a specific user""" return cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc()).limit(limit).all() - + @classmethod def get_recent(cls, limit=100, entity_type=None, user_id=None, action=None): """Get recent audit logs with optional filters""" query = cls.query - + if entity_type: query = query.filter_by(entity_type=entity_type) - + if user_id: query = query.filter_by(user_id=user_id) - + if action: query = query.filter_by(action=action) - + return query.order_by(cls.created_at.desc()).limit(limit).all() - + def to_dict(self): """Convert to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'username': self.user.username if self.user else None, - 'display_name': self.user.display_name if self.user else None, - 'entity_type': self.entity_type, - 'entity_id': self.entity_id, - 'entity_name': self.entity_name, - 'action': self.action, - 'field_name': self.field_name, - 'old_value': self.get_old_value(), - 'new_value': self.get_new_value(), - 'change_description': self.change_description, - 'ip_address': self.ip_address, - 'user_agent': self.user_agent, - 'request_path': self.request_path, - 'created_at': self.created_at.isoformat() if self.created_at else None, + "id": self.id, + "user_id": self.user_id, + "username": self.user.username if self.user else None, + "display_name": self.user.display_name if self.user else None, + "entity_type": self.entity_type, + "entity_id": self.entity_id, + "entity_name": self.entity_name, + "action": self.action, + "field_name": self.field_name, + "old_value": self.get_old_value(), + "new_value": self.get_new_value(), + "change_description": self.change_description, + "ip_address": self.ip_address, + "user_agent": self.user_agent, + "request_path": self.request_path, + "created_at": self.created_at.isoformat() if self.created_at else None, } - + def get_icon(self): """Get icon class for this audit log action""" icons = { - 'created': 'fas fa-plus-circle text-green-500', - 'updated': 'fas fa-edit text-blue-500', - 'deleted': 'fas fa-trash text-red-500', + "created": "fas fa-plus-circle text-green-500", + "updated": "fas fa-edit text-blue-500", + "deleted": "fas fa-trash text-red-500", } - return icons.get(self.action, 'fas fa-circle text-gray-500') - + return icons.get(self.action, "fas fa-circle text-gray-500") + def get_color(self): """Get color class for this audit log action""" colors = { - 'created': 'green', - 'updated': 'blue', - 'deleted': 'red', + "created": "green", + "updated": "blue", + "deleted": "red", } - return colors.get(self.action, 'gray') - + return colors.get(self.action, "gray") diff --git a/app/models/budget_alert.py b/app/models/budget_alert.py index 3f4dba11..c5ffdf44 100644 --- a/app/models/budget_alert.py +++ b/app/models/budget_alert.py @@ -1,35 +1,37 @@ from datetime import datetime, timedelta from app import db + class BudgetAlert(db.Model): """Budget alert model for tracking project budget warnings and notifications""" - - __tablename__ = 'budget_alerts' - + + __tablename__ = "budget_alerts" + id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) - + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + # Alert details alert_type = db.Column(db.String(20), nullable=False) # 'warning_80', 'warning_100', 'over_budget' alert_level = db.Column(db.String(20), nullable=False) # 'info', 'warning', 'critical' budget_consumed_percent = db.Column(db.Numeric(5, 2), nullable=False) # Percentage of budget consumed budget_amount = db.Column(db.Numeric(10, 2), nullable=False) # Budget at time of alert consumed_amount = db.Column(db.Numeric(10, 2), nullable=False) # Amount consumed at time of alert - + # Alert message and status message = db.Column(db.Text, nullable=False) is_acknowledged = db.Column(db.Boolean, default=False, nullable=False) - acknowledged_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + acknowledged_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) acknowledged_at = db.Column(db.DateTime, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) - + # Relationships - project = db.relationship('Project', backref=db.backref('budget_alerts', lazy='dynamic')) - - def __init__(self, project_id, alert_type, alert_level, budget_consumed_percent, - budget_amount, consumed_amount, message): + project = db.relationship("Project", backref=db.backref("budget_alerts", lazy="dynamic")) + + def __init__( + self, project_id, alert_type, alert_level, budget_consumed_percent, budget_amount, consumed_amount, message + ): self.project_id = project_id self.alert_type = alert_type self.alert_level = alert_level @@ -37,73 +39,65 @@ def __init__(self, project_id, alert_type, alert_level, budget_consumed_percent, self.budget_amount = budget_amount self.consumed_amount = consumed_amount self.message = message - + def __repr__(self): - return f'' - + return f"" + def acknowledge(self, user_id): """Mark this alert as acknowledged by a user""" self.is_acknowledged = True self.acknowledged_by = user_id self.acknowledged_at = datetime.utcnow() db.session.commit() - + def to_dict(self): """Convert budget alert to dictionary for API responses""" return { - 'id': self.id, - 'project_id': self.project_id, - 'project_name': self.project.name if self.project else None, - 'alert_type': self.alert_type, - 'alert_level': self.alert_level, - 'budget_consumed_percent': float(self.budget_consumed_percent), - 'budget_amount': float(self.budget_amount), - 'consumed_amount': float(self.consumed_amount), - 'message': self.message, - 'is_acknowledged': self.is_acknowledged, - 'acknowledged_by': self.acknowledged_by, - 'acknowledged_at': self.acknowledged_at.isoformat() if self.acknowledged_at else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, + "id": self.id, + "project_id": self.project_id, + "project_name": self.project.name if self.project else None, + "alert_type": self.alert_type, + "alert_level": self.alert_level, + "budget_consumed_percent": float(self.budget_consumed_percent), + "budget_amount": float(self.budget_amount), + "consumed_amount": float(self.consumed_amount), + "message": self.message, + "is_acknowledged": self.is_acknowledged, + "acknowledged_by": self.acknowledged_by, + "acknowledged_at": self.acknowledged_at.isoformat() if self.acknowledged_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None, } - + @classmethod def get_active_alerts(cls, project_id=None, acknowledged=False): """Get active alerts, optionally filtered by project""" query = cls.query.filter_by(is_acknowledged=acknowledged) - + if project_id: query = query.filter_by(project_id=project_id) - + return query.order_by(cls.created_at.desc()).all() - + @classmethod - def create_alert(cls, project_id, alert_type, budget_consumed_percent, - budget_amount, consumed_amount): + def create_alert(cls, project_id, alert_type, budget_consumed_percent, budget_amount, consumed_amount): """Create a new budget alert""" # Determine alert level based on type - alert_levels = { - 'warning_80': 'warning', - 'warning_100': 'critical', - 'over_budget': 'critical' - } - alert_level = alert_levels.get(alert_type, 'info') - + alert_levels = {"warning_80": "warning", "warning_100": "critical", "over_budget": "critical"} + alert_level = alert_levels.get(alert_type, "info") + # Generate alert message - message = cls._generate_message(alert_type, budget_consumed_percent, - budget_amount, consumed_amount) - + message = cls._generate_message(alert_type, budget_consumed_percent, budget_amount, consumed_amount) + # Check if similar alert already exists (avoid duplicates) - recent_alert = cls.query.filter_by( - project_id=project_id, - alert_type=alert_type, - is_acknowledged=False - ).filter( - cls.created_at >= datetime.utcnow() - timedelta(hours=24) - ).first() - + recent_alert = ( + cls.query.filter_by(project_id=project_id, alert_type=alert_type, is_acknowledged=False) + .filter(cls.created_at >= datetime.utcnow() - timedelta(hours=24)) + .first() + ) + if recent_alert: return recent_alert - + # Create new alert alert = cls( project_id=project_id, @@ -112,39 +106,38 @@ def create_alert(cls, project_id, alert_type, budget_consumed_percent, budget_consumed_percent=budget_consumed_percent, budget_amount=budget_amount, consumed_amount=consumed_amount, - message=message + message=message, ) - + db.session.add(alert) db.session.commit() - + return alert - + @staticmethod def _generate_message(alert_type, budget_consumed_percent, budget_amount, consumed_amount): """Generate alert message based on alert type""" messages = { - 'warning_80': f'Warning: Project has consumed {budget_consumed_percent:.1f}% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})', - 'warning_100': f'Alert: Project has reached 100% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})', - 'over_budget': f'Critical: Project is over budget by ${consumed_amount - budget_amount:.2f} ({budget_consumed_percent:.1f}% consumed)' + "warning_80": f"Warning: Project has consumed {budget_consumed_percent:.1f}% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})", + "warning_100": f"Alert: Project has reached 100% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})", + "over_budget": f"Critical: Project is over budget by ${consumed_amount - budget_amount:.2f} ({budget_consumed_percent:.1f}% consumed)", } - return messages.get(alert_type, 'Budget alert') - + return messages.get(alert_type, "Budget alert") + @classmethod def get_alert_summary(cls, project_id=None): """Get summary statistics for budget alerts""" query = cls.query - + if project_id: query = query.filter_by(project_id=project_id) - + total_alerts = query.count() unacknowledged_alerts = query.filter_by(is_acknowledged=False).count() - critical_alerts = query.filter_by(alert_level='critical', is_acknowledged=False).count() - + critical_alerts = query.filter_by(alert_level="critical", is_acknowledged=False).count() + return { - 'total_alerts': total_alerts, - 'unacknowledged_alerts': unacknowledged_alerts, - 'critical_alerts': critical_alerts + "total_alerts": total_alerts, + "unacknowledged_alerts": unacknowledged_alerts, + "critical_alerts": critical_alerts, } - diff --git a/app/models/calendar_event.py b/app/models/calendar_event.py index 1c6d2b40..2d8a60a0 100644 --- a/app/models/calendar_event.py +++ b/app/models/calendar_event.py @@ -5,62 +5,62 @@ class CalendarEvent(db.Model): """Calendar event model for scheduling meetings, appointments, and other events""" - - __tablename__ = 'calendar_events' - + + __tablename__ = "calendar_events" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) start_time = db.Column(db.DateTime, nullable=False, index=True) end_time = db.Column(db.DateTime, nullable=False, index=True) all_day = db.Column(db.Boolean, default=False, nullable=False) location = db.Column(db.String(200), nullable=True) - + # Event type: meeting, appointment, reminder, deadline, or custom - event_type = db.Column(db.String(50), default='event', nullable=False, index=True) - + event_type = db.Column(db.String(50), default="event", nullable=False, index=True) + # Optional associations - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) - + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + # Recurring event support is_recurring = db.Column(db.Boolean, default=False, nullable=False) recurrence_rule = db.Column(db.String(200), nullable=True) # RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR") recurrence_end_date = db.Column(db.DateTime, nullable=True) - parent_event_id = db.Column(db.Integer, db.ForeignKey('calendar_events.id'), nullable=True, index=True) - + parent_event_id = db.Column(db.Integer, db.ForeignKey("calendar_events.id"), nullable=True, index=True) + # Reminders reminder_minutes = db.Column(db.Integer, nullable=True) # Minutes before event to remind - + # Color coding color = db.Column(db.String(7), nullable=True) # Hex color code (e.g., #FF5733) - + # Privacy is_private = db.Column(db.Boolean, default=False, nullable=False) - + created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False) - + # Relationships - user = db.relationship('User', backref=db.backref('calendar_events', lazy='dynamic', cascade='all, delete-orphan')) - project = db.relationship('Project', backref=db.backref('calendar_events', lazy='dynamic')) - task = db.relationship('Task', backref=db.backref('calendar_events', lazy='dynamic')) - client = db.relationship('Client', backref=db.backref('calendar_events', lazy='dynamic')) - + user = db.relationship("User", backref=db.backref("calendar_events", lazy="dynamic", cascade="all, delete-orphan")) + project = db.relationship("Project", backref=db.backref("calendar_events", lazy="dynamic")) + task = db.relationship("Task", backref=db.backref("calendar_events", lazy="dynamic")) + client = db.relationship("Client", backref=db.backref("calendar_events", lazy="dynamic")) + # For recurring events - parent/child relationship child_events = db.relationship( - 'CalendarEvent', - backref=db.backref('parent_event', remote_side=[id]), + "CalendarEvent", + backref=db.backref("parent_event", remote_side=[id]), foreign_keys=[parent_event_id], - lazy='dynamic', - cascade='all, delete-orphan' + lazy="dynamic", + cascade="all, delete-orphan", ) - + def __init__(self, user_id, title, start_time, end_time, **kwargs): """Initialize a CalendarEvent instance. - + Args: user_id: ID of the user who created this event title: Title of the event @@ -72,65 +72,65 @@ def __init__(self, user_id, title, start_time, end_time, **kwargs): self.title = title self.start_time = start_time self.end_time = end_time - + for key, value in kwargs.items(): if hasattr(self, key): setattr(self, key, value) - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert event to dictionary for API responses""" return { - 'id': self.id, - 'title': self.title, - 'description': self.description, - 'start': self.start_time.isoformat() if self.start_time else None, - 'end': self.end_time.isoformat() if self.end_time else None, - 'allDay': self.all_day, - 'location': self.location, - 'eventType': self.event_type, - 'projectId': self.project_id, - 'taskId': self.task_id, - 'clientId': self.client_id, - 'isRecurring': self.is_recurring, - 'recurrenceRule': self.recurrence_rule, - 'recurrenceEndDate': self.recurrence_end_date.isoformat() if self.recurrence_end_date else None, - 'parentEventId': self.parent_event_id, - 'reminderMinutes': self.reminder_minutes, - 'color': self.color, - 'isPrivate': self.is_private, - 'createdAt': self.created_at.isoformat() if self.created_at else None, - 'updatedAt': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "title": self.title, + "description": self.description, + "start": self.start_time.isoformat() if self.start_time else None, + "end": self.end_time.isoformat() if self.end_time else None, + "allDay": self.all_day, + "location": self.location, + "eventType": self.event_type, + "projectId": self.project_id, + "taskId": self.task_id, + "clientId": self.client_id, + "isRecurring": self.is_recurring, + "recurrenceRule": self.recurrence_rule, + "recurrenceEndDate": self.recurrence_end_date.isoformat() if self.recurrence_end_date else None, + "parentEventId": self.parent_event_id, + "reminderMinutes": self.reminder_minutes, + "color": self.color, + "isPrivate": self.is_private, + "createdAt": self.created_at.isoformat() if self.created_at else None, + "updatedAt": self.updated_at.isoformat() if self.updated_at else None, } - + def duration_hours(self): """Calculate duration of event in hours""" if self.start_time and self.end_time: delta = self.end_time - self.start_time return delta.total_seconds() / 3600 return 0 - + @staticmethod def get_events_in_range(user_id, start_date, end_date, include_tasks=False, include_time_entries=False): """Get all events for a user within a date range. - + Args: user_id: ID of the user start_date: Start of date range end_date: End of date range include_tasks: Whether to include tasks with due dates include_time_entries: Whether to include time entries - + Returns: Dictionary with events, tasks, and time entries """ from app.models import Task, TimeEntry import logging - + logger = logging.getLogger(__name__) - + print(f"\n{'*'*80}") print(f"MODEL - get_events_in_range called:") print(f" user_id={user_id}") @@ -139,26 +139,28 @@ def get_events_in_range(user_id, start_date, end_date, include_tasks=False, incl print(f" include_tasks={include_tasks} (type: {type(include_tasks)})") print(f" include_time_entries={include_time_entries} (type: {type(include_time_entries)})") print(f"{'*'*80}\n") - - logger.info(f"get_events_in_range called: user_id={user_id}, start={start_date}, end={end_date}, include_tasks={include_tasks}, include_time_entries={include_time_entries}") - - result = { - 'events': [], - 'tasks': [], - 'time_entries': [] - } - + + logger.info( + f"get_events_in_range called: user_id={user_id}, start={start_date}, end={end_date}, include_tasks={include_tasks}, include_time_entries={include_time_entries}" + ) + + result = {"events": [], "tasks": [], "time_entries": []} + # Get calendar events - events = CalendarEvent.query.filter( - CalendarEvent.user_id == user_id, - CalendarEvent.start_time >= start_date, - CalendarEvent.start_time <= end_date - ).order_by(CalendarEvent.start_time).all() - + events = ( + CalendarEvent.query.filter( + CalendarEvent.user_id == user_id, + CalendarEvent.start_time >= start_date, + CalendarEvent.start_time <= end_date, + ) + .order_by(CalendarEvent.start_time) + .all() + ) + logger.info(f"Found {len(events)} calendar events") print(f"MODEL - Found {len(events)} calendar events") - result['events'] = [event.to_dict() for event in events] - + result["events"] = [event.to_dict() for event in events] + # Optionally include tasks with due dates if include_tasks: print(f"MODEL - Querying tasks for user {user_id}") @@ -166,62 +168,71 @@ def get_events_in_range(user_id, start_date, end_date, include_tasks=False, incl tasks = Task.query.filter( Task.assigned_to == user_id, Task.due_date.isnot(None), - Task.due_date >= start_date.date() if hasattr(start_date, 'date') else start_date, - Task.due_date <= end_date.date() if hasattr(end_date, 'date') else end_date, - Task.status.in_(['todo', 'in_progress', 'review']) + Task.due_date >= start_date.date() if hasattr(start_date, "date") else start_date, + Task.due_date <= end_date.date() if hasattr(end_date, "date") else end_date, + Task.status.in_(["todo", "in_progress", "review"]), ).all() - + print(f"MODEL - Found {len(tasks)} tasks with due dates") logger.info(f"Found {len(tasks)} tasks with due dates") - - result['tasks'] = [{ - 'id': task.id, - 'title': task.name, - 'description': task.description, - 'dueDate': task.due_date.isoformat() if task.due_date else None, - 'status': task.status, - 'priority': task.priority, - 'projectId': task.project_id, - 'type': 'task' - } for task in tasks] + + result["tasks"] = [ + { + "id": task.id, + "title": task.name, + "description": task.description, + "dueDate": task.due_date.isoformat() if task.due_date else None, + "status": task.status, + "priority": task.priority, + "projectId": task.project_id, + "type": "task", + } + for task in tasks + ] else: print(f"MODEL - Not including tasks (include_tasks=False)") logger.info("Not including tasks (include_tasks=False)") - + # Optionally include time entries if include_time_entries: print(f"MODEL - Querying time entries for user {user_id}") logger.info(f"Querying time entries for user {user_id}") - time_entries = TimeEntry.query.filter( - TimeEntry.user_id == user_id, - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date - ).order_by(TimeEntry.start_time).all() - + time_entries = ( + TimeEntry.query.filter( + TimeEntry.user_id == user_id, TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date + ) + .order_by(TimeEntry.start_time) + .all() + ) + print(f"MODEL - Found {len(time_entries)} time entries") logger.info(f"Found {len(time_entries)} time entries") - - result['time_entries'] = [{ - 'id': entry.id, - 'title': f"Time: {entry.project.name if entry.project else 'Unknown'}", - 'start': entry.start_time.isoformat() if entry.start_time else None, - 'end': entry.end_time.isoformat() if entry.end_time else None, - 'projectId': entry.project_id, - 'taskId': entry.task_id, - 'notes': entry.notes, - 'type': 'time_entry' - } for entry in time_entries] + + result["time_entries"] = [ + { + "id": entry.id, + "title": f"Time: {entry.project.name if entry.project else 'Unknown'}", + "start": entry.start_time.isoformat() if entry.start_time else None, + "end": entry.end_time.isoformat() if entry.end_time else None, + "projectId": entry.project_id, + "taskId": entry.task_id, + "notes": entry.notes, + "type": "time_entry", + } + for entry in time_entries + ] else: print(f"MODEL - Not including time entries (include_time_entries=False)") logger.info("Not including time entries (include_time_entries=False)") - + print(f"\n{'*'*80}") print(f"MODEL - Returning:") print(f" events: {len(result['events'])}") print(f" tasks: {len(result['tasks'])}") print(f" time_entries: {len(result['time_entries'])}") print(f"{'*'*80}\n") - - logger.info(f"Returning: {len(result['events'])} events, {len(result['tasks'])} tasks, {len(result['time_entries'])} time_entries") - return result + logger.info( + f"Returning: {len(result['events'])} events, {len(result['tasks'])} tasks, {len(result['time_entries'])} time_entries" + ) + return result diff --git a/app/models/calendar_integration.py b/app/models/calendar_integration.py index f10ccf03..a66b5027 100644 --- a/app/models/calendar_integration.py +++ b/app/models/calendar_integration.py @@ -1,88 +1,88 @@ """Calendar integration models""" + from datetime import datetime from app import db class CalendarIntegration(db.Model): """User calendar integration configuration""" - - __tablename__ = 'calendar_integrations' - + + __tablename__ = "calendar_integrations" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Provider provider = db.Column(db.String(50), nullable=False, index=True) # Provider: 'google', 'outlook', 'ical' - + # OAuth tokens (encrypted) access_token = db.Column(db.Text, nullable=False) # Encrypted refresh_token = db.Column(db.Text, nullable=True) # Encrypted token_expires_at = db.Column(db.DateTime, nullable=True) - + # Calendar ID calendar_id = db.Column(db.String(200), nullable=True) calendar_name = db.Column(db.String(200), nullable=True) - + # Sync settings (JSON) # Contains: sync_direction (bidirectional, time_to_calendar, calendar_to_time), # sync_frequency, auto_create_events, etc. sync_settings = db.Column(db.JSON, nullable=False, default=dict) - + # Status is_active = db.Column(db.Boolean, default=True, nullable=False, index=True) last_sync_at = db.Column(db.DateTime, nullable=True) last_sync_status = db.Column(db.String(20), nullable=True) # Status: 'success', 'error', 'partial' last_sync_error = db.Column(db.Text, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - user = db.relationship('User', backref='calendar_integrations') - + user = db.relationship("User", backref="calendar_integrations") + def __repr__(self): - return f'' + return f"" class CalendarSyncEvent(db.Model): """Calendar sync event tracking""" - - __tablename__ = 'calendar_sync_events' - + + __tablename__ = "calendar_sync_events" + id = db.Column(db.Integer, primary_key=True) - integration_id = db.Column(db.Integer, db.ForeignKey('calendar_integrations.id'), nullable=False, index=True) - + integration_id = db.Column(db.Integer, db.ForeignKey("calendar_integrations.id"), nullable=False, index=True) + # Event type event_type = db.Column(db.String(50), nullable=False, index=True) # Type: 'time_entry_created', 'time_entry_updated', 'calendar_event_created', etc. - + # Related entities - time_entry_id = db.Column(db.Integer, db.ForeignKey('time_entries.id'), nullable=True, index=True) + time_entry_id = db.Column(db.Integer, db.ForeignKey("time_entries.id"), nullable=True, index=True) calendar_event_id = db.Column(db.String(200), nullable=True, index=True) # External calendar event ID - + # Sync direction direction = db.Column(db.String(20), nullable=False) # Direction: 'to_calendar', 'from_calendar' - + # Status status = db.Column(db.String(20), nullable=False, index=True) # Status: 'pending', 'synced', 'failed', 'skipped' - + # Error information error_message = db.Column(db.Text, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) synced_at = db.Column(db.DateTime, nullable=True) - + # Relationships - integration = db.relationship('CalendarIntegration', backref='sync_events') - time_entry = db.relationship('TimeEntry', backref='calendar_sync_events') - - def __repr__(self): - return f'' + integration = db.relationship("CalendarIntegration", backref="sync_events") + time_entry = db.relationship("TimeEntry", backref="calendar_sync_events") + def __repr__(self): + return f"" diff --git a/app/models/client.py b/app/models/client.py index f51497f2..bc96bbfa 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -5,11 +5,12 @@ from .client_prepaid_consumption import ClientPrepaidConsumption import secrets + class Client(db.Model): """Client model for managing client information and rates""" - - __tablename__ = 'clients' - + + __tablename__ = "clients" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False, unique=True, index=True) description = db.Column(db.Text, nullable=True) @@ -18,25 +19,37 @@ class Client(db.Model): phone = db.Column(db.String(50), nullable=True) address = db.Column(db.Text, nullable=True) default_hourly_rate = db.Column(db.Numeric(9, 2), nullable=True) - status = db.Column(db.String(20), default='active', nullable=False) # 'active' or 'inactive' + status = db.Column(db.String(20), default="active", nullable=False) # 'active' or 'inactive' prepaid_hours_monthly = db.Column(db.Numeric(7, 2), nullable=True) prepaid_reset_day = db.Column(db.Integer, nullable=False, default=1) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Client portal settings portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access portal_username = db.Column(db.String(80), unique=True, nullable=True, index=True) # Portal login username portal_password_hash = db.Column(db.String(255), nullable=True) # Hashed password for portal access password_setup_token = db.Column(db.String(100), nullable=True, index=True) # Token for password setup/reset password_setup_token_expires = db.Column(db.DateTime, nullable=True) # Token expiration time - + # Relationships - projects = db.relationship('Project', backref='client_obj', lazy='dynamic', cascade='all, delete-orphan') - - def __init__(self, name, description=None, contact_person=None, email=None, phone=None, address=None, default_hourly_rate=None, company=None, prepaid_hours_monthly=None, prepaid_reset_day=1): + projects = db.relationship("Project", backref="client_obj", lazy="dynamic", cascade="all, delete-orphan") + + def __init__( + self, + name, + description=None, + contact_person=None, + email=None, + phone=None, + address=None, + default_hourly_rate=None, + company=None, + prepaid_hours_monthly=None, + prepaid_reset_day=1, + ): """Create a Client. - + Note: company parameter is accepted for test compatibility but not used, as the Client model uses 'name' as the primary identifier. """ @@ -47,31 +60,33 @@ def __init__(self, name, description=None, contact_person=None, email=None, phon self.phone = phone.strip() if phone else None self.address = address.strip() if address else None self.default_hourly_rate = Decimal(str(default_hourly_rate)) if default_hourly_rate else None - self.prepaid_hours_monthly = Decimal(str(prepaid_hours_monthly)) if prepaid_hours_monthly not in (None, '') else None + self.prepaid_hours_monthly = ( + Decimal(str(prepaid_hours_monthly)) if prepaid_hours_monthly not in (None, "") else None + ) try: reset_day = int(prepaid_reset_day) if prepaid_reset_day is not None else 1 self.prepaid_reset_day = max(1, min(28, reset_day)) except (TypeError, ValueError): self.prepaid_reset_day = 1 - + def __repr__(self): - return f'' - + return f"" + @property def is_active(self): """Check if client is active""" - return self.status == 'active' - + return self.status == "active" + @property def total_projects(self): """Get total number of projects for this client""" return self.projects.count() - + @property def active_projects(self): """Get number of active projects for this client""" - return self.projects.filter_by(status='active').count() - + return self.projects.filter_by(status="active").count() + @property def total_hours(self): """Calculate total hours across all projects for this client""" @@ -79,7 +94,7 @@ def total_hours(self): for project in self.projects: total_seconds += project.total_hours * 3600 # Convert hours to seconds return round(total_seconds / 3600, 2) - + @property def total_billable_hours(self): """Calculate total billable hours across all projects for this client""" @@ -87,7 +102,7 @@ def total_billable_hours(self): for project in self.projects: total_seconds += project.total_billable_hours * 3600 # Convert hours to seconds return round(total_seconds / 3600, 2) - + @property def estimated_total_cost(self): """Calculate estimated total cost based on billable hours and rates""" @@ -101,20 +116,20 @@ def estimated_total_cost(self): def prepaid_plan_enabled(self): """Return True if client has prepaid hours configured.""" try: - hours = Decimal(str(self.prepaid_hours_monthly)) if self.prepaid_hours_monthly is not None else Decimal('0') + hours = Decimal(str(self.prepaid_hours_monthly)) if self.prepaid_hours_monthly is not None else Decimal("0") except Exception: - hours = Decimal('0') + hours = Decimal("0") return hours > 0 @property def prepaid_hours_decimal(self): """Return prepaid hours as Decimal with two decimal precision.""" if self.prepaid_hours_monthly is None: - return Decimal('0') + return Decimal("0") try: - return Decimal(str(self.prepaid_hours_monthly)).quantize(Decimal('0.01')) + return Decimal(str(self.prepaid_hours_monthly)).quantize(Decimal("0.01")) except Exception: - return Decimal('0') + return Decimal("0") def prepaid_month_start(self, reference_datetime): """ @@ -134,7 +149,7 @@ def prepaid_month_start(self, reference_datetime): reset_day = max(1, min(28, int(reset_day))) dt = reference_datetime - if isinstance(dt, datetime) and hasattr(dt, 'date'): + if isinstance(dt, datetime) and hasattr(dt, "date"): dt_date = dt.date() else: dt_date = dt @@ -151,67 +166,70 @@ def prepaid_month_start(self, reference_datetime): def get_prepaid_consumed_hours(self, month_start): """Return Decimal hours consumed for the given prepaid cycle.""" if not month_start: - return Decimal('0') + return Decimal("0") try: - seconds = self.prepaid_consumptions.filter( - ClientPrepaidConsumption.allocation_month == month_start - ).with_entities( - db.func.coalesce(db.func.sum(ClientPrepaidConsumption.seconds_consumed), 0) - ).scalar() or 0 + seconds = ( + self.prepaid_consumptions.filter(ClientPrepaidConsumption.allocation_month == month_start) + .with_entities(db.func.coalesce(db.func.sum(ClientPrepaidConsumption.seconds_consumed), 0)) + .scalar() + or 0 + ) except Exception: seconds = 0 - return Decimal(seconds) / Decimal('3600') + return Decimal(seconds) / Decimal("3600") def get_prepaid_remaining_hours(self, month_start): """Return how many prepaid hours remain for the cycle starting at month_start.""" if not self.prepaid_plan_enabled or not month_start: - return Decimal('0') + return Decimal("0") consumed = self.get_prepaid_consumed_hours(month_start) remaining = self.prepaid_hours_decimal - consumed - return remaining if remaining > 0 else Decimal('0') - + return remaining if remaining > 0 else Decimal("0") + def archive(self): """Archive the client""" - self.status = 'inactive' + self.status = "inactive" self.updated_at = datetime.utcnow() - + def activate(self): """Activate the client""" - self.status = 'active' + self.status = "active" self.updated_at = datetime.utcnow() - + def to_dict(self): """Convert client to dictionary for JSON serialization""" return { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'contact_person': self.contact_person, - 'email': self.email, - 'phone': self.phone, - 'address': self.address, - 'default_hourly_rate': str(self.default_hourly_rate) if self.default_hourly_rate else None, - 'status': self.status, - 'is_active': self.is_active, - 'total_projects': self.total_projects, - 'active_projects': self.active_projects, - 'prepaid_hours_monthly': float(self.prepaid_hours_monthly) if self.prepaid_hours_monthly is not None else None, - 'prepaid_reset_day': self.prepaid_reset_day, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "name": self.name, + "description": self.description, + "contact_person": self.contact_person, + "email": self.email, + "phone": self.phone, + "address": self.address, + "default_hourly_rate": str(self.default_hourly_rate) if self.default_hourly_rate else None, + "status": self.status, + "is_active": self.is_active, + "total_projects": self.total_projects, + "active_projects": self.active_projects, + "prepaid_hours_monthly": ( + float(self.prepaid_hours_monthly) if self.prepaid_hours_monthly is not None else None + ), + "prepaid_reset_day": self.prepaid_reset_day, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_active_clients(cls): """Get all active clients ordered by name""" - return cls.query.filter_by(status='active').order_by(cls.name).all() - + return cls.query.filter_by(status="active").order_by(cls.name).all() + @classmethod def get_all_clients(cls): """Get all clients ordered by name""" return cls.query.order_by(cls.name).all() - + # Client portal helpers def set_portal_password(self, password): """Set the portal password for this client""" @@ -219,103 +237,95 @@ def set_portal_password(self, password): self.portal_password_hash = generate_password_hash(password) else: self.portal_password_hash = None - + def check_portal_password(self, password): """Check if the provided password matches the portal password""" if not self.portal_password_hash or not password: return False return check_password_hash(self.portal_password_hash, password) - + @property def has_portal_access(self): """Check if client has portal access enabled and credentials set""" return self.portal_enabled and self.portal_username and self.portal_password_hash - + def get_portal_data(self): """Get data for client portal view (projects, invoices, time entries)""" if not self.has_portal_access: return None - + from .project import Project from .invoice import Invoice from .time_entry import TimeEntry - + # Get active projects for this client - projects = Project.query.filter_by( - client_id=self.id, - status='active' - ).order_by(Project.name).all() - + projects = Project.query.filter_by(client_id=self.id, status="active").order_by(Project.name).all() + # Get invoices for this client - invoices = Invoice.query.filter_by( - client_id=self.id - ).order_by(Invoice.issue_date.desc()).limit(50).all() - + invoices = Invoice.query.filter_by(client_id=self.id).order_by(Invoice.issue_date.desc()).limit(50).all() + # Get time entries for projects belonging to this client project_ids = [p.id for p in projects] - time_entries = TimeEntry.query.filter( - TimeEntry.project_id.in_(project_ids), - TimeEntry.end_time.isnot(None) - ).order_by(TimeEntry.start_time.desc()).limit(100).all() - - return { - 'client': self, - 'projects': projects, - 'invoices': invoices, - 'time_entries': time_entries - } - + time_entries = ( + TimeEntry.query.filter(TimeEntry.project_id.in_(project_ids), TimeEntry.end_time.isnot(None)) + .order_by(TimeEntry.start_time.desc()) + .limit(100) + .all() + ) + + return {"client": self, "projects": projects, "invoices": invoices, "time_entries": time_entries} + def generate_password_setup_token(self, expires_hours=24): """Generate a secure token for password setup/reset""" token = secrets.token_urlsafe(32) self.password_setup_token = token self.password_setup_token_expires = datetime.utcnow() + timedelta(hours=expires_hours) return token - + def verify_password_setup_token(self, token): """Verify if a password setup token is valid""" if not self.password_setup_token or not token: return False - + if self.password_setup_token != token: return False - + if self.password_setup_token_expires and self.password_setup_token_expires < datetime.utcnow(): return False - + return True - + def clear_password_setup_token(self): """Clear the password setup token after use""" self.password_setup_token = None self.password_setup_token_expires = None - + @classmethod def authenticate_portal(cls, username, password): """Authenticate a client portal login""" client = cls.query.filter_by(portal_username=username, portal_enabled=True).first() if not client: return None - + if not client.check_portal_password(password): return None - + if not client.is_active: return None - + return client - + @classmethod def find_by_password_token(cls, token): """Find a client by password setup token""" if not token: return None - + client = cls.query.filter_by(password_setup_token=token).first() if not client: return None - + if client.password_setup_token_expires and client.password_setup_token_expires < datetime.utcnow(): return None - + return client diff --git a/app/models/client_note.py b/app/models/client_note.py index ed8cdc54..46ca8695 100644 --- a/app/models/client_note.py +++ b/app/models/client_note.py @@ -2,34 +2,35 @@ from app import db from app.utils.timezone import now_in_app_timezone + class ClientNote(db.Model): """ClientNote model for internal notes about clients""" - - __tablename__ = 'client_notes' - + + __tablename__ = "client_notes" + id = db.Column(db.Integer, primary_key=True) content = db.Column(db.Text, nullable=False) - + # Reference to client - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) - + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True) + # Author of the note - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Internal flag - these notes are always internal and not visible to clients is_important = db.Column(db.Boolean, default=False, nullable=False) - + # Timestamps created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False) - + # Relationships - author = db.relationship('User', backref='client_notes') - client = db.relationship('Client', backref=db.backref('notes', cascade='all, delete-orphan')) - + author = db.relationship("User", backref="client_notes") + client = db.relationship("Client", backref=db.backref("notes", cascade="all, delete-orphan")) + def __init__(self, content, user_id, client_id, is_important=False): """Create a client note. - + Args: content: The note text user_id: ID of the user creating the note @@ -38,41 +39,41 @@ def __init__(self, content, user_id, client_id, is_important=False): """ if not client_id: raise ValueError("Note must be associated with a client") - + if not content or not content.strip(): raise ValueError("Note content cannot be empty") - + self.content = content.strip() self.user_id = user_id self.client_id = client_id self.is_important = is_important - + def __repr__(self): return f'' - + @property def author_name(self): """Get the author's display name""" if self.author: return self.author.full_name if self.author.full_name else self.author.username - return 'Unknown' - + return "Unknown" + @property def client_name(self): """Get the client name""" - return self.client.name if self.client else 'Unknown' - + return self.client.name if self.client else "Unknown" + def can_edit(self, user): """Check if a user can edit this note""" return user.id == self.user_id or user.is_admin - + def can_delete(self, user): """Check if a user can delete this note""" return user.id == self.user_id or user.is_admin - + def edit_content(self, new_content, user, is_important=None): """Edit the note content - + Args: new_content: New content for the note user: User making the edit @@ -80,70 +81,69 @@ def edit_content(self, new_content, user, is_important=None): """ if not self.can_edit(user): raise PermissionError("User does not have permission to edit this note") - + if not new_content or not new_content.strip(): raise ValueError("Note content cannot be empty") - + self.content = new_content.strip() if is_important is not None: self.is_important = is_important self.updated_at = now_in_app_timezone() - + def to_dict(self): """Convert note to dictionary for API responses""" return { - 'id': self.id, - 'content': self.content, - 'client_id': self.client_id, - 'client_name': self.client_name, - 'user_id': self.user_id, - 'author': self.author.username if self.author else None, - 'author_full_name': self.author.full_name if self.author and self.author.full_name else None, - 'author_name': self.author_name, - 'is_important': self.is_important, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "content": self.content, + "client_id": self.client_id, + "client_name": self.client_name, + "user_id": self.user_id, + "author": self.author.username if self.author else None, + "author_full_name": self.author.full_name if self.author and self.author.full_name else None, + "author_name": self.author_name, + "is_important": self.is_important, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_client_notes(cls, client_id, order_by_important=False): """Get all notes for a client - + Args: client_id: ID of the client order_by_important: If True, important notes appear first """ query = cls.query.filter_by(client_id=client_id) - + if order_by_important: query = query.order_by(cls.is_important.desc(), cls.created_at.desc()) else: query = query.order_by(cls.created_at.desc()) - + return query.all() - + @classmethod def get_important_notes(cls, client_id=None): """Get all important notes, optionally filtered by client""" query = cls.query.filter_by(is_important=True) - + if client_id: query = query.filter_by(client_id=client_id) - + return query.order_by(cls.created_at.desc()).all() - + @classmethod def get_user_notes(cls, user_id, limit=None): """Get recent notes by a user""" query = cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc()) - + if limit: query = query.limit(limit) - + return query.all() - + @classmethod def get_recent_notes(cls, limit=10): """Get recent notes across all clients""" return cls.query.order_by(cls.created_at.desc()).limit(limit).all() - diff --git a/app/models/client_prepaid_consumption.py b/app/models/client_prepaid_consumption.py index 7567f868..6a88c38b 100644 --- a/app/models/client_prepaid_consumption.py +++ b/app/models/client_prepaid_consumption.py @@ -7,30 +7,31 @@ class ClientPrepaidConsumption(db.Model): """Ledger entries tracking which time entries consumed prepaid hours.""" - __tablename__ = 'client_prepaid_consumptions' + __tablename__ = "client_prepaid_consumptions" id = db.Column(db.Integer, primary_key=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) - time_entry_id = db.Column(db.Integer, db.ForeignKey('time_entries.id'), nullable=False, unique=True, index=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True) + time_entry_id = db.Column(db.Integer, db.ForeignKey("time_entries.id"), nullable=False, unique=True, index=True) + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=True, index=True) allocation_month = db.Column(db.Date, nullable=False, index=True) seconds_consumed = db.Column(db.Integer, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) # Relationships - client = db.relationship('Client', backref=db.backref('prepaid_consumptions', lazy='dynamic', cascade='all, delete-orphan')) - time_entry = db.relationship('TimeEntry', backref=db.backref('prepaid_consumption', uselist=False)) - invoice = db.relationship('Invoice', backref=db.backref('prepaid_consumptions', lazy='dynamic')) + client = db.relationship( + "Client", backref=db.backref("prepaid_consumptions", lazy="dynamic", cascade="all, delete-orphan") + ) + time_entry = db.relationship("TimeEntry", backref=db.backref("prepaid_consumption", uselist=False)) + invoice = db.relationship("Invoice", backref=db.backref("prepaid_consumptions", lazy="dynamic")) def __repr__(self): - month = self.allocation_month.isoformat() if self.allocation_month else '?' - return f'' + month = self.allocation_month.isoformat() if self.allocation_month else "?" + return f"" @property def hours_consumed(self) -> Decimal: """Return consumed prepaid hours as Decimal.""" if not self.seconds_consumed: - return Decimal('0') - return (Decimal(self.seconds_consumed) / Decimal('3600')).quantize(Decimal('0.01')) - + return Decimal("0") + return (Decimal(self.seconds_consumed) / Decimal("3600")).quantize(Decimal("0.01")) diff --git a/app/models/comment.py b/app/models/comment.py index a81828fa..39db0038 100644 --- a/app/models/comment.py +++ b/app/models/comment.py @@ -2,44 +2,47 @@ from app import db from app.utils.timezone import now_in_app_timezone + class Comment(db.Model): """Comment model for project and task discussions""" - - __tablename__ = 'comments' - + + __tablename__ = "comments" + id = db.Column(db.Integer, primary_key=True) content = db.Column(db.Text, nullable=False) - + # Reference to either project, task, or quote (one will be null) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) - quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id', ondelete='CASCADE'), nullable=True, index=True) - + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) + quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id", ondelete="CASCADE"), nullable=True, index=True) + # Author of the comment - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Visibility: True = internal team comment, False = client-visible comment is_internal = db.Column(db.Boolean, default=True, nullable=False) - + # Timestamps created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False) - + # Optional: for threaded comments (replies to other comments) - parent_id = db.Column(db.Integer, db.ForeignKey('comments.id'), nullable=True, index=True) - + parent_id = db.Column(db.Integer, db.ForeignKey("comments.id"), nullable=True, index=True) + # Relationships - author = db.relationship('User', backref='comments') - project = db.relationship('Project', backref='comments') - task = db.relationship('Task', backref='comments') - quote = db.relationship('Quote', backref='comments') - + author = db.relationship("User", backref="comments") + project = db.relationship("Project", backref="comments") + task = db.relationship("Task", backref="comments") + quote = db.relationship("Quote", backref="comments") + # Self-referential relationship for replies - parent = db.relationship('Comment', remote_side=[id], backref='replies') - - def __init__(self, content, user_id, project_id=None, task_id=None, quote_id=None, parent_id=None, is_internal=True): + parent = db.relationship("Comment", remote_side=[id], backref="replies") + + def __init__( + self, content, user_id, project_id=None, task_id=None, quote_id=None, parent_id=None, is_internal=True + ): """Create a comment. - + Args: content: The comment text user_id: ID of the user creating the comment @@ -49,12 +52,12 @@ def __init__(self, content, user_id, project_id=None, task_id=None, quote_id=Non """ if not project_id and not task_id and not quote_id: raise ValueError("Comment must be associated with either a project, task, or quote") - + # Ensure only one target is set targets = [x for x in [project_id, task_id, quote_id] if x is not None] if len(targets) > 1: raise ValueError("Comment cannot be associated with multiple targets") - + self.content = content.strip() self.user_id = user_id self.project_id = project_id @@ -62,7 +65,7 @@ def __init__(self, content, user_id, project_id=None, task_id=None, quote_id=Non self.quote_id = quote_id self.parent_id = parent_id self.is_internal = is_internal - + def __repr__(self): if self.project_id: target = f"Project {self.project_id}" @@ -73,23 +76,23 @@ def __repr__(self): else: target = "Unknown" return f'' - + @property def is_reply(self): """Check if this comment is a reply to another comment""" return self.parent_id is not None - + @property def target_type(self): """Get the type of target this comment is attached to""" if self.project_id: - return 'project' + return "project" elif self.task_id: - return 'task' + return "task" elif self.quote_id: - return 'quote' - return 'unknown' - + return "quote" + return "unknown" + @property def target_name(self): """Get the name of the target this comment is attached to""" @@ -99,35 +102,35 @@ def target_name(self): return self.task.name elif self.quote_id and self.quote: return self.quote.title - return 'Unknown' - + return "Unknown" + @property def reply_count(self): """Get the number of replies to this comment""" return len(self.replies) if self.replies else 0 - + def can_edit(self, user): """Check if a user can edit this comment""" return user.id == self.user_id or user.is_admin - + def can_delete(self, user): """Check if a user can delete this comment""" return user.id == self.user_id or user.is_admin - + def edit_content(self, new_content, user): """Edit the comment content""" if not self.can_edit(user): raise PermissionError("User does not have permission to edit this comment") - + self.content = new_content.strip() self.updated_at = now_in_app_timezone() db.session.commit() - + def delete_comment(self, user): """Delete the comment (soft delete by clearing content)""" if not self.can_delete(user): raise PermissionError("User does not have permission to delete this comment") - + # If the comment has replies, we'll mark it as deleted but keep the structure if self.replies: self.content = "[Comment deleted]" @@ -135,73 +138,73 @@ def delete_comment(self, user): else: # If no replies, we can safely delete it db.session.delete(self) - + db.session.commit() - + def to_dict(self): """Convert comment to dictionary for API responses""" return { - 'id': self.id, - 'content': self.content, - 'project_id': self.project_id, - 'task_id': self.task_id, - 'quote_id': self.quote_id, - 'user_id': self.user_id, - 'author': self.author.username if self.author else None, - 'author_full_name': self.author.full_name if self.author and self.author.full_name else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'parent_id': self.parent_id, - 'is_reply': self.is_reply, - 'reply_count': self.reply_count, - 'target_type': self.target_type, - 'target_name': self.target_name, - 'is_internal': self.is_internal + "id": self.id, + "content": self.content, + "project_id": self.project_id, + "task_id": self.task_id, + "quote_id": self.quote_id, + "user_id": self.user_id, + "author": self.author.username if self.author else None, + "author_full_name": self.author.full_name if self.author and self.author.full_name else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "parent_id": self.parent_id, + "is_reply": self.is_reply, + "reply_count": self.reply_count, + "target_type": self.target_type, + "target_name": self.target_name, + "is_internal": self.is_internal, } - + @classmethod def get_project_comments(cls, project_id, include_replies=True): """Get all comments for a project""" query = cls.query.filter_by(project_id=project_id) - + if not include_replies: query = query.filter_by(parent_id=None) - + return query.order_by(cls.created_at.asc()).all() - + @classmethod def get_task_comments(cls, task_id, include_replies=True): """Get all comments for a task""" query = cls.query.filter_by(task_id=task_id) - + if not include_replies: query = query.filter_by(parent_id=None) - + return query.order_by(cls.created_at.asc()).all() - + @classmethod def get_user_comments(cls, user_id, limit=None): """Get recent comments by a user""" query = cls.query.filter_by(user_id=user_id).order_by(cls.created_at.desc()) - + if limit: query = query.limit(limit) - + return query.all() - + @classmethod def get_quote_comments(cls, quote_id, include_replies=True, include_internal=True): """Get all comments for a quote""" query = cls.query.filter_by(quote_id=quote_id) - + if not include_internal: query = query.filter_by(is_internal=False) - + if not include_replies: query = query.filter_by(parent_id=None) - + return query.order_by(cls.created_at.asc()).all() - + @classmethod def get_recent_comments(cls, limit=10): """Get recent comments across all projects, tasks, and quotes""" diff --git a/app/models/contact.py b/app/models/contact.py index 308c52b9..ef2d786b 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -2,108 +2,116 @@ from app import db from app.utils.timezone import now_in_app_timezone + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) + class Contact(db.Model): """Contact model for managing multiple contacts per client""" - - __tablename__ = 'contacts' - + + __tablename__ = "contacts" + id = db.Column(db.Integer, primary_key=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) - + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True) + # Contact information first_name = db.Column(db.String(100), nullable=False) last_name = db.Column(db.String(100), nullable=False) email = db.Column(db.String(200), nullable=True, index=True) phone = db.Column(db.String(50), nullable=True) mobile = db.Column(db.String(50), nullable=True) - + # Contact details title = db.Column(db.String(100), nullable=True) # Job title department = db.Column(db.String(100), nullable=True) - role = db.Column(db.String(50), nullable=True, default='contact') # 'primary', 'billing', 'technical', 'contact' + role = db.Column(db.String(50), nullable=True, default="contact") # 'primary', 'billing', 'technical', 'contact' is_primary = db.Column(db.Boolean, default=False, nullable=False) # Primary contact for client - + # Additional information address = db.Column(db.Text, nullable=True) notes = db.Column(db.Text, nullable=True) tags = db.Column(db.String(500), nullable=True) # Comma-separated tags - + # Status is_active = db.Column(db.Boolean, default=True, nullable=False) - + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Relationships - client = db.relationship('Client', backref='contacts') - creator = db.relationship('User', foreign_keys=[created_by], backref='created_contacts') - communications = db.relationship('ContactCommunication', foreign_keys='ContactCommunication.contact_id', backref='contact', lazy='dynamic', cascade='all, delete-orphan') - + client = db.relationship("Client", backref="contacts") + creator = db.relationship("User", foreign_keys=[created_by], backref="created_contacts") + communications = db.relationship( + "ContactCommunication", + foreign_keys="ContactCommunication.contact_id", + backref="contact", + lazy="dynamic", + cascade="all, delete-orphan", + ) + def __init__(self, client_id, first_name, last_name, created_by, **kwargs): self.client_id = client_id self.first_name = first_name.strip() self.last_name = last_name.strip() self.created_by = created_by - + # Set optional fields - self.email = kwargs.get('email', '').strip() if kwargs.get('email') else None - self.phone = kwargs.get('phone', '').strip() if kwargs.get('phone') else None - self.mobile = kwargs.get('mobile', '').strip() if kwargs.get('mobile') else None - self.title = kwargs.get('title', '').strip() if kwargs.get('title') else None - self.department = kwargs.get('department', '').strip() if kwargs.get('department') else None - self.role = kwargs.get('role', 'contact').strip() if kwargs.get('role') else 'contact' - self.is_primary = kwargs.get('is_primary', False) - self.address = kwargs.get('address', '').strip() if kwargs.get('address') else None - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - self.tags = kwargs.get('tags', '').strip() if kwargs.get('tags') else None - self.is_active = kwargs.get('is_active', True) - + self.email = kwargs.get("email", "").strip() if kwargs.get("email") else None + self.phone = kwargs.get("phone", "").strip() if kwargs.get("phone") else None + self.mobile = kwargs.get("mobile", "").strip() if kwargs.get("mobile") else None + self.title = kwargs.get("title", "").strip() if kwargs.get("title") else None + self.department = kwargs.get("department", "").strip() if kwargs.get("department") else None + self.role = kwargs.get("role", "contact").strip() if kwargs.get("role") else "contact" + self.is_primary = kwargs.get("is_primary", False) + self.address = kwargs.get("address", "").strip() if kwargs.get("address") else None + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + self.tags = kwargs.get("tags", "").strip() if kwargs.get("tags") else None + self.is_active = kwargs.get("is_active", True) + def __repr__(self): - return f'' - + return f"" + @property def full_name(self): """Get full name of contact""" return f"{self.first_name} {self.last_name}".strip() - + @property def display_name(self): """Get display name with title if available""" if self.title: return f"{self.full_name} - {self.title}" return self.full_name - + def to_dict(self): """Convert contact to dictionary for JSON serialization""" return { - 'id': self.id, - 'client_id': self.client_id, - 'first_name': self.first_name, - 'last_name': self.last_name, - 'full_name': self.full_name, - 'display_name': self.display_name, - 'email': self.email, - 'phone': self.phone, - 'mobile': self.mobile, - 'title': self.title, - 'department': self.department, - 'role': self.role, - 'is_primary': self.is_primary, - 'address': self.address, - 'notes': self.notes, - 'tags': self.tags.split(',') if self.tags else [], - 'is_active': self.is_active, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "client_id": self.client_id, + "first_name": self.first_name, + "last_name": self.last_name, + "full_name": self.full_name, + "display_name": self.display_name, + "email": self.email, + "phone": self.phone, + "mobile": self.mobile, + "title": self.title, + "department": self.department, + "role": self.role, + "is_primary": self.is_primary, + "address": self.address, + "notes": self.notes, + "tags": self.tags.split(",") if self.tags else [], + "is_active": self.is_active, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_active_contacts(cls, client_id=None): """Get active contacts, optionally filtered by client""" @@ -111,16 +119,15 @@ def get_active_contacts(cls, client_id=None): if client_id: query = query.filter_by(client_id=client_id) return query.order_by(cls.last_name, cls.first_name).all() - + @classmethod def get_primary_contact(cls, client_id): """Get primary contact for a client""" return cls.query.filter_by(client_id=client_id, is_primary=True, is_active=True).first() - + def set_as_primary(self): """Set this contact as primary and unset others for the same client""" # Unset other primary contacts for this client - Contact.query.filter_by(client_id=self.client_id, is_primary=True).update({'is_primary': False}) + Contact.query.filter_by(client_id=self.client_id, is_primary=True).update({"is_primary": False}) self.is_primary = True db.session.commit() - diff --git a/app/models/contact_communication.py b/app/models/contact_communication.py index 5c810d0a..b022947d 100644 --- a/app/models/contact_communication.py +++ b/app/models/contact_communication.py @@ -2,89 +2,91 @@ from app import db from app.utils.timezone import now_in_app_timezone + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) + class ContactCommunication(db.Model): """Model for tracking communications with contacts""" - - __tablename__ = 'contact_communications' - + + __tablename__ = "contact_communications" + id = db.Column(db.Integer, primary_key=True) - contact_id = db.Column(db.Integer, db.ForeignKey('contacts.id'), nullable=False, index=True) - + contact_id = db.Column(db.Integer, db.ForeignKey("contacts.id"), nullable=False, index=True) + # Communication details type = db.Column(db.String(50), nullable=False) # 'email', 'call', 'meeting', 'note', 'message' subject = db.Column(db.String(500), nullable=True) content = db.Column(db.Text, nullable=True) - + # Direction - direction = db.Column(db.String(20), nullable=False, default='outbound') # 'inbound', 'outbound' - + direction = db.Column(db.String(20), nullable=False, default="outbound") # 'inbound', 'outbound' + # Dates communication_date = db.Column(db.DateTime, nullable=False, default=local_now, index=True) follow_up_date = db.Column(db.DateTime, nullable=True) # When to follow up - + # Status status = db.Column(db.String(50), nullable=True) # 'completed', 'pending', 'scheduled', 'cancelled' - + # Related entities - related_project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - related_quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id'), nullable=True, index=True) - related_deal_id = db.Column(db.Integer, db.ForeignKey('deals.id'), nullable=True, index=True) - + related_project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + related_quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id"), nullable=True, index=True) + related_deal_id = db.Column(db.Integer, db.ForeignKey("deals.id"), nullable=True, index=True) + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Relationships # Note: 'contact' backref is created by Contact.communications relationship - creator = db.relationship('User', foreign_keys=[created_by], backref='created_communications') - related_project = db.relationship('Project', foreign_keys=[related_project_id]) - related_quote = db.relationship('Quote', foreign_keys=[related_quote_id]) - related_deal = db.relationship('Deal', foreign_keys=[related_deal_id]) - + creator = db.relationship("User", foreign_keys=[created_by], backref="created_communications") + related_project = db.relationship("Project", foreign_keys=[related_project_id]) + related_quote = db.relationship("Quote", foreign_keys=[related_quote_id]) + related_deal = db.relationship("Deal", foreign_keys=[related_deal_id]) + def __init__(self, contact_id, type, created_by, **kwargs): self.contact_id = contact_id self.type = type.strip() self.created_by = created_by - + # Set optional fields - self.subject = kwargs.get('subject', '').strip() if kwargs.get('subject') else None - self.content = kwargs.get('content', '').strip() if kwargs.get('content') else None - self.direction = kwargs.get('direction', 'outbound').strip() - self.status = kwargs.get('status', 'completed').strip() if kwargs.get('status') else None - self.communication_date = kwargs.get('communication_date') or local_now() - self.follow_up_date = kwargs.get('follow_up_date') - self.related_project_id = kwargs.get('related_project_id') - self.related_quote_id = kwargs.get('related_quote_id') - self.related_deal_id = kwargs.get('related_deal_id') - + self.subject = kwargs.get("subject", "").strip() if kwargs.get("subject") else None + self.content = kwargs.get("content", "").strip() if kwargs.get("content") else None + self.direction = kwargs.get("direction", "outbound").strip() + self.status = kwargs.get("status", "completed").strip() if kwargs.get("status") else None + self.communication_date = kwargs.get("communication_date") or local_now() + self.follow_up_date = kwargs.get("follow_up_date") + self.related_project_id = kwargs.get("related_project_id") + self.related_quote_id = kwargs.get("related_quote_id") + self.related_deal_id = kwargs.get("related_deal_id") + def __repr__(self): return f'' - + def to_dict(self): """Convert communication to dictionary""" return { - 'id': self.id, - 'contact_id': self.contact_id, - 'type': self.type, - 'subject': self.subject, - 'content': self.content, - 'direction': self.direction, - 'status': self.status, - 'communication_date': self.communication_date.isoformat() if self.communication_date else None, - 'follow_up_date': self.follow_up_date.isoformat() if self.follow_up_date else None, - 'related_project_id': self.related_project_id, - 'related_quote_id': self.related_quote_id, - 'related_deal_id': self.related_deal_id, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "contact_id": self.contact_id, + "type": self.type, + "subject": self.subject, + "content": self.content, + "direction": self.direction, + "status": self.status, + "communication_date": self.communication_date.isoformat() if self.communication_date else None, + "follow_up_date": self.follow_up_date.isoformat() if self.follow_up_date else None, + "related_project_id": self.related_project_id, + "related_quote_id": self.related_quote_id, + "related_deal_id": self.related_deal_id, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_recent_communications(cls, contact_id=None, limit=50): """Get recent communications, optionally filtered by contact""" @@ -92,4 +94,3 @@ def get_recent_communications(cls, contact_id=None, limit=50): if contact_id: query = query.filter_by(contact_id=contact_id) return query.order_by(cls.communication_date.desc()).limit(limit).all() - diff --git a/app/models/currency.py b/app/models/currency.py index 577c09dc..b8bb28dc 100644 --- a/app/models/currency.py +++ b/app/models/currency.py @@ -5,7 +5,7 @@ class Currency(db.Model): """Supported currencies and display metadata.""" - __tablename__ = 'currencies' + __tablename__ = "currencies" code = db.Column(db.String(3), primary_key=True) # e.g., EUR, USD name = db.Column(db.String(64), nullable=False) @@ -22,11 +22,11 @@ def __repr__(self): class ExchangeRate(db.Model): """Daily exchange rates between currency pairs.""" - __tablename__ = 'exchange_rates' + __tablename__ = "exchange_rates" id = db.Column(db.Integer, primary_key=True) - base_code = db.Column(db.String(3), db.ForeignKey('currencies.code'), nullable=False, index=True) - quote_code = db.Column(db.String(3), db.ForeignKey('currencies.code'), nullable=False, index=True) + base_code = db.Column(db.String(3), db.ForeignKey("currencies.code"), nullable=False, index=True) + quote_code = db.Column(db.String(3), db.ForeignKey("currencies.code"), nullable=False, index=True) rate = db.Column(db.Numeric(18, 8), nullable=False) date = db.Column(db.Date, nullable=False, index=True) source = db.Column(db.String(50), nullable=True) # e.g., ECB, exchangerate.host @@ -34,11 +34,7 @@ class ExchangeRate(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - __table_args__ = ( - db.UniqueConstraint('base_code', 'quote_code', 'date', name='uq_exchange_rate_day'), - ) + __table_args__ = (db.UniqueConstraint("base_code", "quote_code", "date", name="uq_exchange_rate_day"),) def __repr__(self): return f"" - - diff --git a/app/models/deal.py b/app/models/deal.py index cb8a7a4d..a37bcc8c 100644 --- a/app/models/deal.py +++ b/app/models/deal.py @@ -3,170 +3,171 @@ from app import db from app.utils.timezone import now_in_app_timezone + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) + class Deal(db.Model): """Deal/Opportunity model for sales pipeline management""" - - __tablename__ = 'deals' - + + __tablename__ = "deals" + id = db.Column(db.Integer, primary_key=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) # Can be null for leads - contact_id = db.Column(db.Integer, db.ForeignKey('contacts.id'), nullable=True, index=True) - lead_id = db.Column(db.Integer, db.ForeignKey('leads.id'), nullable=True, index=True) # If converted from lead - + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) # Can be null for leads + contact_id = db.Column(db.Integer, db.ForeignKey("contacts.id"), nullable=True, index=True) + lead_id = db.Column(db.Integer, db.ForeignKey("leads.id"), nullable=True, index=True) # If converted from lead + # Deal information name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - + # Pipeline stage - stage = db.Column(db.String(50), nullable=False, default='prospecting', index=True) + stage = db.Column(db.String(50), nullable=False, default="prospecting", index=True) # Common stages: 'prospecting', 'qualification', 'proposal', 'negotiation', 'closed_won', 'closed_lost' - + # Financial details value = db.Column(db.Numeric(10, 2), nullable=True) # Deal value - currency_code = db.Column(db.String(3), nullable=False, default='EUR') + currency_code = db.Column(db.String(3), nullable=False, default="EUR") probability = db.Column(db.Integer, nullable=True, default=50) # Win probability (0-100) expected_close_date = db.Column(db.Date, nullable=True, index=True) actual_close_date = db.Column(db.Date, nullable=True) - + # Status - status = db.Column(db.String(20), default='open', nullable=False) # 'open', 'won', 'lost', 'cancelled' - + status = db.Column(db.String(20), default="open", nullable=False) # 'open', 'won', 'lost', 'cancelled' + # Loss reason (if lost) loss_reason = db.Column(db.String(500), nullable=True) - + # Related entities - related_quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id'), nullable=True, index=True) - related_project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - + related_quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id"), nullable=True, index=True) + related_project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + # Notes notes = db.Column(db.Text, nullable=True) - + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) # Deal owner + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) # Deal owner created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) closed_at = db.Column(db.DateTime, nullable=True) - + # Relationships - client = db.relationship('Client', backref='deals') - contact = db.relationship('Contact', backref='deals') - lead = db.relationship('Lead', foreign_keys=[lead_id], backref='deals') - creator = db.relationship('User', foreign_keys=[created_by], backref='created_deals') - owner = db.relationship('User', foreign_keys=[owner_id], backref='owned_deals') - related_quote = db.relationship('Quote', foreign_keys=[related_quote_id]) - related_project = db.relationship('Project', foreign_keys=[related_project_id]) - activities = db.relationship('DealActivity', backref='deal', lazy='dynamic', cascade='all, delete-orphan') - + client = db.relationship("Client", backref="deals") + contact = db.relationship("Contact", backref="deals") + lead = db.relationship("Lead", foreign_keys=[lead_id], backref="deals") + creator = db.relationship("User", foreign_keys=[created_by], backref="created_deals") + owner = db.relationship("User", foreign_keys=[owner_id], backref="owned_deals") + related_quote = db.relationship("Quote", foreign_keys=[related_quote_id]) + related_project = db.relationship("Project", foreign_keys=[related_project_id]) + activities = db.relationship("DealActivity", backref="deal", lazy="dynamic", cascade="all, delete-orphan") + def __init__(self, name, created_by, **kwargs): self.name = name.strip() self.created_by = created_by - + # Set optional fields - self.client_id = kwargs.get('client_id') - self.contact_id = kwargs.get('contact_id') - self.lead_id = kwargs.get('lead_id') - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.stage = kwargs.get('stage', 'prospecting').strip() - self.value = Decimal(str(kwargs.get('value'))) if kwargs.get('value') else None - self.currency_code = kwargs.get('currency_code', 'EUR') - self.probability = kwargs.get('probability', 50) - self.expected_close_date = kwargs.get('expected_close_date') - self.status = kwargs.get('status', 'open').strip() - self.loss_reason = kwargs.get('loss_reason', '').strip() if kwargs.get('loss_reason') else None - self.related_quote_id = kwargs.get('related_quote_id') - self.related_project_id = kwargs.get('related_project_id') - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - self.owner_id = kwargs.get('owner_id', created_by) # Default to creator - + self.client_id = kwargs.get("client_id") + self.contact_id = kwargs.get("contact_id") + self.lead_id = kwargs.get("lead_id") + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.stage = kwargs.get("stage", "prospecting").strip() + self.value = Decimal(str(kwargs.get("value"))) if kwargs.get("value") else None + self.currency_code = kwargs.get("currency_code", "EUR") + self.probability = kwargs.get("probability", 50) + self.expected_close_date = kwargs.get("expected_close_date") + self.status = kwargs.get("status", "open").strip() + self.loss_reason = kwargs.get("loss_reason", "").strip() if kwargs.get("loss_reason") else None + self.related_quote_id = kwargs.get("related_quote_id") + self.related_project_id = kwargs.get("related_project_id") + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + self.owner_id = kwargs.get("owner_id", created_by) # Default to creator + def __repr__(self): - return f'' - + return f"" + @property def weighted_value(self): """Calculate probability-weighted value""" if not self.value: - return Decimal('0') + return Decimal("0") return self.value * (Decimal(str(self.probability)) / 100) - + @property def is_open(self): """Check if deal is still open""" - return self.status == 'open' - + return self.status == "open" + @property def is_won(self): """Check if deal is won""" - return self.status == 'won' - + return self.status == "won" + @property def is_lost(self): """Check if deal is lost""" - return self.status == 'lost' - + return self.status == "lost" + def close_won(self, close_date=None): """Mark deal as won""" - self.status = 'won' - self.stage = 'closed_won' + self.status = "won" + self.stage = "closed_won" self.actual_close_date = close_date or local_now().date() self.closed_at = local_now() self.updated_at = local_now() - + def close_lost(self, reason=None, close_date=None): """Mark deal as lost""" - self.status = 'lost' - self.stage = 'closed_lost' + self.status = "lost" + self.stage = "closed_lost" self.actual_close_date = close_date or local_now().date() self.closed_at = local_now() if reason: self.loss_reason = reason self.updated_at = local_now() - + def to_dict(self): """Convert deal to dictionary""" return { - 'id': self.id, - 'client_id': self.client_id, - 'contact_id': self.contact_id, - 'lead_id': self.lead_id, - 'name': self.name, - 'description': self.description, - 'stage': self.stage, - 'value': float(self.value) if self.value else None, - 'currency_code': self.currency_code, - 'probability': self.probability, - 'weighted_value': float(self.weighted_value), - 'expected_close_date': self.expected_close_date.isoformat() if self.expected_close_date else None, - 'actual_close_date': self.actual_close_date.isoformat() if self.actual_close_date else None, - 'status': self.status, - 'loss_reason': self.loss_reason, - 'related_quote_id': self.related_quote_id, - 'related_project_id': self.related_project_id, - 'notes': self.notes, - 'created_by': self.created_by, - 'owner_id': self.owner_id, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'closed_at': self.closed_at.isoformat() if self.closed_at else None, - 'is_open': self.is_open, - 'is_won': self.is_won, - 'is_lost': self.is_lost + "id": self.id, + "client_id": self.client_id, + "contact_id": self.contact_id, + "lead_id": self.lead_id, + "name": self.name, + "description": self.description, + "stage": self.stage, + "value": float(self.value) if self.value else None, + "currency_code": self.currency_code, + "probability": self.probability, + "weighted_value": float(self.weighted_value), + "expected_close_date": self.expected_close_date.isoformat() if self.expected_close_date else None, + "actual_close_date": self.actual_close_date.isoformat() if self.actual_close_date else None, + "status": self.status, + "loss_reason": self.loss_reason, + "related_quote_id": self.related_quote_id, + "related_project_id": self.related_project_id, + "notes": self.notes, + "created_by": self.created_by, + "owner_id": self.owner_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "closed_at": self.closed_at.isoformat() if self.closed_at else None, + "is_open": self.is_open, + "is_won": self.is_won, + "is_lost": self.is_lost, } - + @classmethod def get_open_deals(cls, user_id=None): """Get open deals, optionally filtered by owner""" - query = cls.query.filter_by(status='open') + query = cls.query.filter_by(status="open") if user_id: query = query.filter_by(owner_id=user_id) return query.order_by(cls.expected_close_date, cls.created_at.desc()).all() - + @classmethod def get_deals_by_stage(cls, stage): """Get deals by pipeline stage""" - return cls.query.filter_by(stage=stage, status='open').order_by(cls.expected_close_date).all() - + return cls.query.filter_by(stage=stage, status="open").order_by(cls.expected_close_date).all() diff --git a/app/models/deal_activity.py b/app/models/deal_activity.py index ef01aa6b..7bead5ee 100644 --- a/app/models/deal_activity.py +++ b/app/models/deal_activity.py @@ -2,65 +2,68 @@ from app import db from app.utils.timezone import now_in_app_timezone + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) + class DealActivity(db.Model): """Model for tracking activities on deals""" - - __tablename__ = 'deal_activities' - + + __tablename__ = "deal_activities" + id = db.Column(db.Integer, primary_key=True) - deal_id = db.Column(db.Integer, db.ForeignKey('deals.id'), nullable=False, index=True) - + deal_id = db.Column(db.Integer, db.ForeignKey("deals.id"), nullable=False, index=True) + # Activity details - type = db.Column(db.String(50), nullable=False) # 'call', 'email', 'meeting', 'note', 'stage_change', 'status_change' + type = db.Column( + db.String(50), nullable=False + ) # 'call', 'email', 'meeting', 'note', 'stage_change', 'status_change' subject = db.Column(db.String(500), nullable=True) description = db.Column(db.Text, nullable=True) - + # Activity date activity_date = db.Column(db.DateTime, nullable=False, default=local_now, index=True) due_date = db.Column(db.DateTime, nullable=True) # For scheduled activities - + # Status - status = db.Column(db.String(50), nullable=True, default='completed') # 'completed', 'pending', 'cancelled' - + status = db.Column(db.String(50), nullable=True, default="completed") # 'completed', 'pending', 'cancelled' + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=local_now, nullable=False) - + # Relationships # Note: 'deal' backref is created by Deal.activities relationship - creator = db.relationship('User', foreign_keys=[created_by], backref='created_deal_activities') - + creator = db.relationship("User", foreign_keys=[created_by], backref="created_deal_activities") + def __init__(self, deal_id, type, created_by, **kwargs): self.deal_id = deal_id self.type = type.strip() self.created_by = created_by - + # Set optional fields - self.subject = kwargs.get('subject', '').strip() if kwargs.get('subject') else None - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.activity_date = kwargs.get('activity_date') or local_now() - self.due_date = kwargs.get('due_date') - self.status = kwargs.get('status', 'completed').strip() if kwargs.get('status') else 'completed' - + self.subject = kwargs.get("subject", "").strip() if kwargs.get("subject") else None + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.activity_date = kwargs.get("activity_date") or local_now() + self.due_date = kwargs.get("due_date") + self.status = kwargs.get("status", "completed").strip() if kwargs.get("status") else "completed" + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert activity to dictionary""" return { - 'id': self.id, - 'deal_id': self.deal_id, - 'type': self.type, - 'subject': self.subject, - 'description': self.description, - 'activity_date': self.activity_date.isoformat() if self.activity_date else None, - 'due_date': self.due_date.isoformat() if self.due_date else None, - 'status': self.status, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() if self.created_at else None + "id": self.id, + "deal_id": self.deal_id, + "type": self.type, + "subject": self.subject, + "description": self.description, + "activity_date": self.activity_date.isoformat() if self.activity_date else None, + "due_date": self.due_date.isoformat() if self.due_date else None, + "status": self.status, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, } - diff --git a/app/models/expense.py b/app/models/expense.py index c8f045de..41d08f03 100644 --- a/app/models/expense.py +++ b/app/models/expense.py @@ -6,375 +6,379 @@ class Expense(db.Model): """Expense tracking model for business expenses""" - - __tablename__ = 'expenses' - + + __tablename__ = "expenses" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + # Expense details title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - category = db.Column(db.String(50), nullable=False) # 'travel', 'meals', 'accommodation', 'supplies', 'software', 'equipment', 'services', 'other' + category = db.Column( + db.String(50), nullable=False + ) # 'travel', 'meals', 'accommodation', 'supplies', 'software', 'equipment', 'services', 'other' amount = db.Column(db.Numeric(10, 2), nullable=False) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Tax information tax_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0) tax_rate = db.Column(db.Numeric(5, 2), nullable=True, default=0) # Percentage - + # Payment information - payment_method = db.Column(db.String(50), nullable=True) # 'cash', 'credit_card', 'bank_transfer', 'company_card', etc. + payment_method = db.Column( + db.String(50), nullable=True + ) # 'cash', 'credit_card', 'bank_transfer', 'company_card', etc. payment_date = db.Column(db.Date, nullable=True) - + # Status and approval - status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed' - approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + status = db.Column( + db.String(20), default="pending", nullable=False + ) # 'pending', 'approved', 'rejected', 'reimbursed' + approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) approved_at = db.Column(db.DateTime, nullable=True) rejection_reason = db.Column(db.Text, nullable=True) - + # Billing and invoicing billable = db.Column(db.Boolean, default=False, nullable=False) reimbursable = db.Column(db.Boolean, default=True, nullable=False) invoiced = db.Column(db.Boolean, default=False, nullable=False) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=True, index=True) + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=True, index=True) reimbursed = db.Column(db.Boolean, default=False, nullable=False) reimbursed_at = db.Column(db.DateTime, nullable=True) - + # Date and metadata expense_date = db.Column(db.Date, nullable=False, index=True) receipt_path = db.Column(db.String(500), nullable=True) receipt_number = db.Column(db.String(100), nullable=True) vendor = db.Column(db.String(200), nullable=True) notes = db.Column(db.Text, nullable=True) - + # Tags for categorization tags = db.Column(db.String(500), nullable=True) # Comma-separated tags - + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('expenses', lazy='dynamic')) - approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_expenses', lazy='dynamic')) - project = db.relationship('Project', backref=db.backref('expenses', lazy='dynamic')) - client = db.relationship('Client', backref=db.backref('expenses', lazy='dynamic')) - invoice = db.relationship('Invoice', backref=db.backref('expenses', lazy='dynamic')) - + user = db.relationship("User", foreign_keys=[user_id], backref=db.backref("expenses", lazy="dynamic")) + approver = db.relationship( + "User", foreign_keys=[approved_by], backref=db.backref("approved_expenses", lazy="dynamic") + ) + project = db.relationship("Project", backref=db.backref("expenses", lazy="dynamic")) + client = db.relationship("Client", backref=db.backref("expenses", lazy="dynamic")) + invoice = db.relationship("Invoice", backref=db.backref("expenses", lazy="dynamic")) + # Add composite indexes for common query patterns __table_args__ = ( - Index('ix_expenses_user_date', 'user_id', 'expense_date'), - Index('ix_expenses_status_date', 'status', 'expense_date'), - Index('ix_expenses_project_date', 'project_id', 'expense_date'), + Index("ix_expenses_user_date", "user_id", "expense_date"), + Index("ix_expenses_status_date", "status", "expense_date"), + Index("ix_expenses_project_date", "project_id", "expense_date"), ) - + def __init__(self, user_id, title, category, amount, expense_date, **kwargs): self.user_id = user_id self.title = title.strip() if title else None self.category = category self.amount = Decimal(str(amount)) self.expense_date = expense_date - + # Optional fields - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.project_id = kwargs.get('project_id') - self.client_id = kwargs.get('client_id') - self.currency_code = kwargs.get('currency_code', 'EUR') - self.tax_amount = Decimal(str(kwargs.get('tax_amount', 0))) - self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0))) - self.payment_method = kwargs.get('payment_method') - self.payment_date = kwargs.get('payment_date') - self.billable = kwargs.get('billable', False) - self.reimbursable = kwargs.get('reimbursable', True) - self.receipt_path = kwargs.get('receipt_path') - self.receipt_number = kwargs.get('receipt_number') - self.vendor = kwargs.get('vendor') - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - self.tags = kwargs.get('tags') - self.status = kwargs.get('status', 'pending') - + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.project_id = kwargs.get("project_id") + self.client_id = kwargs.get("client_id") + self.currency_code = kwargs.get("currency_code", "EUR") + self.tax_amount = Decimal(str(kwargs.get("tax_amount", 0))) + self.tax_rate = Decimal(str(kwargs.get("tax_rate", 0))) + self.payment_method = kwargs.get("payment_method") + self.payment_date = kwargs.get("payment_date") + self.billable = kwargs.get("billable", False) + self.reimbursable = kwargs.get("reimbursable", True) + self.receipt_path = kwargs.get("receipt_path") + self.receipt_number = kwargs.get("receipt_number") + self.vendor = kwargs.get("vendor") + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + self.tags = kwargs.get("tags") + self.status = kwargs.get("status", "pending") + def __repr__(self): - return f'' - + return f"" + @property def is_approved(self): """Check if expense is approved""" - return self.status == 'approved' - + return self.status == "approved" + @property def is_rejected(self): """Check if expense is rejected""" - return self.status == 'rejected' - + return self.status == "rejected" + @property def is_reimbursed(self): """Check if expense has been reimbursed""" return self.reimbursed and self.reimbursed_at is not None - + @property def is_invoiced(self): """Check if this expense has been invoiced""" return self.invoiced and self.invoice_id is not None - + @property def total_amount(self): """Calculate total amount including tax""" return self.amount + (self.tax_amount or 0) - + @property def tag_list(self): """Get list of tags""" if not self.tags: return [] - return [tag.strip() for tag in self.tags.split(',') if tag.strip()] - + return [tag.strip() for tag in self.tags.split(",") if tag.strip()] + def approve(self, approved_by_user_id, notes=None): """Approve the expense""" - self.status = 'approved' + self.status = "approved" self.approved_by = approved_by_user_id self.approved_at = datetime.utcnow() if notes: - self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}' + self.notes = (self.notes or "") + f"\n\nApproval notes: {notes}" self.updated_at = datetime.utcnow() - + def reject(self, rejected_by_user_id, reason): """Reject the expense""" - self.status = 'rejected' + self.status = "rejected" self.approved_by = rejected_by_user_id self.approved_at = datetime.utcnow() self.rejection_reason = reason self.updated_at = datetime.utcnow() - + def mark_as_reimbursed(self): """Mark this expense as reimbursed""" self.reimbursed = True self.reimbursed_at = datetime.utcnow() - self.status = 'reimbursed' + self.status = "reimbursed" self.updated_at = datetime.utcnow() - + def mark_as_invoiced(self, invoice_id): """Mark this expense as invoiced""" self.invoiced = True self.invoice_id = invoice_id self.updated_at = datetime.utcnow() - + def unmark_as_invoiced(self): """Unmark this expense as invoiced (e.g., if invoice is deleted)""" self.invoiced = False self.invoice_id = None self.updated_at = datetime.utcnow() - + def to_dict(self): """Convert expense to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'project_id': self.project_id, - 'client_id': self.client_id, - 'title': self.title, - 'description': self.description, - 'category': self.category, - 'amount': float(self.amount), - 'currency_code': self.currency_code, - 'tax_amount': float(self.tax_amount) if self.tax_amount else 0, - 'tax_rate': float(self.tax_rate) if self.tax_rate else 0, - 'total_amount': float(self.total_amount), - 'payment_method': self.payment_method, - 'payment_date': self.payment_date.isoformat() if self.payment_date else None, - 'status': self.status, - 'approved_by': self.approved_by, - 'approved_at': self.approved_at.isoformat() if self.approved_at else None, - 'rejection_reason': self.rejection_reason, - 'billable': self.billable, - 'reimbursable': self.reimbursable, - 'invoiced': self.invoiced, - 'invoice_id': self.invoice_id, - 'reimbursed': self.reimbursed, - 'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None, - 'expense_date': self.expense_date.isoformat() if self.expense_date else None, - 'receipt_path': self.receipt_path, - 'receipt_number': self.receipt_number, - 'vendor': self.vendor, - 'notes': self.notes, - 'tags': self.tag_list, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'user': self.user.username if self.user else None, - 'project': self.project.name if self.project else None, - 'client': self.client.name if self.client else None, - 'approver': self.approver.username if self.approver else None + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "client_id": self.client_id, + "title": self.title, + "description": self.description, + "category": self.category, + "amount": float(self.amount), + "currency_code": self.currency_code, + "tax_amount": float(self.tax_amount) if self.tax_amount else 0, + "tax_rate": float(self.tax_rate) if self.tax_rate else 0, + "total_amount": float(self.total_amount), + "payment_method": self.payment_method, + "payment_date": self.payment_date.isoformat() if self.payment_date else None, + "status": self.status, + "approved_by": self.approved_by, + "approved_at": self.approved_at.isoformat() if self.approved_at else None, + "rejection_reason": self.rejection_reason, + "billable": self.billable, + "reimbursable": self.reimbursable, + "invoiced": self.invoiced, + "invoice_id": self.invoice_id, + "reimbursed": self.reimbursed, + "reimbursed_at": self.reimbursed_at.isoformat() if self.reimbursed_at else None, + "expense_date": self.expense_date.isoformat() if self.expense_date else None, + "receipt_path": self.receipt_path, + "receipt_number": self.receipt_number, + "vendor": self.vendor, + "notes": self.notes, + "tags": self.tag_list, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "user": self.user.username if self.user else None, + "project": self.project.name if self.project else None, + "client": self.client.name if self.client else None, + "approver": self.approver.username if self.approver else None, } - + @classmethod - def get_expenses(cls, user_id=None, project_id=None, client_id=None, - start_date=None, end_date=None, status=None, - category=None, billable_only=False, reimbursable_only=False): + def get_expenses( + cls, + user_id=None, + project_id=None, + client_id=None, + start_date=None, + end_date=None, + status=None, + category=None, + billable_only=False, + reimbursable_only=False, + ): """Get expenses with optional filters""" query = cls.query - + if user_id: query = query.filter(cls.user_id == user_id) - + if project_id: query = query.filter(cls.project_id == project_id) - + if client_id: query = query.filter(cls.client_id == client_id) - + if start_date: query = query.filter(cls.expense_date >= start_date) - + if end_date: query = query.filter(cls.expense_date <= end_date) - + if status: query = query.filter(cls.status == status) - + if category: query = query.filter(cls.category == category) - + if billable_only: query = query.filter(cls.billable == True) - + if reimbursable_only: query = query.filter(cls.reimbursable == True) - + return query.order_by(cls.expense_date.desc()).all() - + @classmethod - def get_total_expenses(cls, user_id=None, project_id=None, client_id=None, - start_date=None, end_date=None, status=None, - category=None, include_tax=True): + def get_total_expenses( + cls, + user_id=None, + project_id=None, + client_id=None, + start_date=None, + end_date=None, + status=None, + category=None, + include_tax=True, + ): """Calculate total expenses with optional filters""" query = db.session.query( db.func.sum(cls.amount if not include_tax else cls.amount + db.func.coalesce(cls.tax_amount, 0)) ) - + if user_id: query = query.filter(cls.user_id == user_id) - + if project_id: query = query.filter(cls.project_id == project_id) - + if client_id: query = query.filter(cls.client_id == client_id) - + if start_date: query = query.filter(cls.expense_date >= start_date) - + if end_date: query = query.filter(cls.expense_date <= end_date) - + if status: query = query.filter(cls.status == status) - + if category: query = query.filter(cls.category == category) - - total = query.scalar() or Decimal('0') + + total = query.scalar() or Decimal("0") return float(total) - + @classmethod def get_expenses_by_category(cls, user_id=None, start_date=None, end_date=None, status=None): """Get expenses grouped by category""" query = db.session.query( cls.category, - db.func.sum(cls.amount + db.func.coalesce(cls.tax_amount, 0)).label('total_amount'), - db.func.count(cls.id).label('count') + db.func.sum(cls.amount + db.func.coalesce(cls.tax_amount, 0)).label("total_amount"), + db.func.count(cls.id).label("count"), ) - + if user_id: query = query.filter(cls.user_id == user_id) - + if start_date: query = query.filter(cls.expense_date >= start_date) - + if end_date: query = query.filter(cls.expense_date <= end_date) - + if status: query = query.filter(cls.status == status) - + results = query.group_by(cls.category).all() - + return [ - { - 'category': category, - 'total_amount': float(total_amount), - 'count': count - } + {"category": category, "total_amount": float(total_amount), "count": count} for category, total_amount, count in results ] - + @classmethod def get_pending_approvals(cls, user_id=None): """Get expenses pending approval""" - query = cls.query.filter_by(status='pending') - + query = cls.query.filter_by(status="pending") + if user_id: query = query.filter(cls.user_id == user_id) - + return query.order_by(cls.expense_date.desc()).all() - + @classmethod def get_pending_reimbursements(cls, user_id=None): """Get approved expenses pending reimbursement""" - query = cls.query.filter( - cls.status == 'approved', - cls.reimbursable == True, - cls.reimbursed == False - ) - + query = cls.query.filter(cls.status == "approved", cls.reimbursable == True, cls.reimbursed == False) + if user_id: query = query.filter(cls.user_id == user_id) - + return query.order_by(cls.expense_date.desc()).all() - + @classmethod def get_uninvoiced_expenses(cls, project_id=None, client_id=None): """Get billable expenses that haven't been invoiced yet""" - query = cls.query.filter( - cls.status == 'approved', - cls.billable == True, - cls.invoiced == False - ) - + query = cls.query.filter(cls.status == "approved", cls.billable == True, cls.invoiced == False) + if project_id: query = query.filter(cls.project_id == project_id) - + if client_id: query = query.filter(cls.client_id == client_id) - + return query.order_by(cls.expense_date.desc()).all() - + @classmethod def get_expense_categories(cls): """Get list of available expense categories""" return [ - 'travel', - 'meals', - 'accommodation', - 'supplies', - 'software', - 'equipment', - 'services', - 'marketing', - 'training', - 'other' + "travel", + "meals", + "accommodation", + "supplies", + "software", + "equipment", + "services", + "marketing", + "training", + "other", ] - + @classmethod def get_payment_methods(cls): """Get list of available payment methods""" - return [ - 'cash', - 'credit_card', - 'debit_card', - 'bank_transfer', - 'company_card', - 'paypal', - 'other' - ] - + return ["cash", "credit_card", "debit_card", "bank_transfer", "company_card", "paypal", "other"] diff --git a/app/models/expense_category.py b/app/models/expense_category.py index e6c0ff45..0a8454e2 100644 --- a/app/models/expense_category.py +++ b/app/models/expense_category.py @@ -6,139 +6,133 @@ class ExpenseCategory(db.Model): """Expense category model with budget tracking""" - - __tablename__ = 'expense_categories' - + + __tablename__ = "expense_categories" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False, unique=True, index=True) description = db.Column(db.Text, nullable=True) code = db.Column(db.String(20), nullable=True, unique=True, index=True) # Short code for quick reference color = db.Column(db.String(7), nullable=True) # Hex color for UI (e.g., #FF5733) icon = db.Column(db.String(50), nullable=True) # Icon name for UI - + # Budget settings monthly_budget = db.Column(db.Numeric(10, 2), nullable=True) quarterly_budget = db.Column(db.Numeric(10, 2), nullable=True) yearly_budget = db.Column(db.Numeric(10, 2), nullable=True) budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # Alert when exceeded - + # Settings requires_receipt = db.Column(db.Boolean, default=True, nullable=False) requires_approval = db.Column(db.Boolean, default=True, nullable=False) default_tax_rate = db.Column(db.Numeric(5, 2), nullable=True) is_active = db.Column(db.Boolean, default=True, nullable=False) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + def __init__(self, name, **kwargs): self.name = name.strip() - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.code = kwargs.get('code', '').strip() if kwargs.get('code') else None - self.color = kwargs.get('color') - self.icon = kwargs.get('icon') - self.monthly_budget = Decimal(str(kwargs.get('monthly_budget'))) if kwargs.get('monthly_budget') else None - self.quarterly_budget = Decimal(str(kwargs.get('quarterly_budget'))) if kwargs.get('quarterly_budget') else None - self.yearly_budget = Decimal(str(kwargs.get('yearly_budget'))) if kwargs.get('yearly_budget') else None - self.budget_threshold_percent = kwargs.get('budget_threshold_percent', 80) - self.requires_receipt = kwargs.get('requires_receipt', True) - self.requires_approval = kwargs.get('requires_approval', True) - self.default_tax_rate = Decimal(str(kwargs.get('default_tax_rate'))) if kwargs.get('default_tax_rate') else None - self.is_active = kwargs.get('is_active', True) - + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.code = kwargs.get("code", "").strip() if kwargs.get("code") else None + self.color = kwargs.get("color") + self.icon = kwargs.get("icon") + self.monthly_budget = Decimal(str(kwargs.get("monthly_budget"))) if kwargs.get("monthly_budget") else None + self.quarterly_budget = Decimal(str(kwargs.get("quarterly_budget"))) if kwargs.get("quarterly_budget") else None + self.yearly_budget = Decimal(str(kwargs.get("yearly_budget"))) if kwargs.get("yearly_budget") else None + self.budget_threshold_percent = kwargs.get("budget_threshold_percent", 80) + self.requires_receipt = kwargs.get("requires_receipt", True) + self.requires_approval = kwargs.get("requires_approval", True) + self.default_tax_rate = Decimal(str(kwargs.get("default_tax_rate"))) if kwargs.get("default_tax_rate") else None + self.is_active = kwargs.get("is_active", True) + def __repr__(self): - return f'' - + return f"" + def get_spent_amount(self, start_date, end_date): """Get total amount spent in this category for date range""" from app.models.expense import Expense - - query = db.session.query( - db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0)) - ).filter( + + query = db.session.query(db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))).filter( Expense.category == self.name, - Expense.status.in_(['approved', 'reimbursed']), + Expense.status.in_(["approved", "reimbursed"]), Expense.expense_date >= start_date, - Expense.expense_date <= end_date + Expense.expense_date <= end_date, ) - - total = query.scalar() or Decimal('0') + + total = query.scalar() or Decimal("0") return float(total) - - def get_budget_utilization(self, period='monthly'): + + def get_budget_utilization(self, period="monthly"): """Get budget utilization percentage for the current period""" from datetime import date - + today = date.today() - - if period == 'monthly': + + if period == "monthly": start_date = date(today.year, today.month, 1) budget = self.monthly_budget - elif period == 'quarterly': + elif period == "quarterly": quarter = (today.month - 1) // 3 + 1 start_month = (quarter - 1) * 3 + 1 start_date = date(today.year, start_month, 1) budget = self.quarterly_budget - elif period == 'yearly': + elif period == "yearly": start_date = date(today.year, 1, 1) budget = self.yearly_budget else: return None - + if not budget or budget == 0: return None - + spent = self.get_spent_amount(start_date, today) utilization = (spent / float(budget)) * 100 - + return { - 'spent': spent, - 'budget': float(budget), - 'utilization_percent': round(utilization, 2), - 'remaining': float(budget) - spent, - 'over_threshold': utilization >= self.budget_threshold_percent + "spent": spent, + "budget": float(budget), + "utilization_percent": round(utilization, 2), + "remaining": float(budget) - spent, + "over_threshold": utilization >= self.budget_threshold_percent, } - + def to_dict(self): """Convert category to dictionary for API responses""" return { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'code': self.code, - 'color': self.color, - 'icon': self.icon, - 'monthly_budget': float(self.monthly_budget) if self.monthly_budget else None, - 'quarterly_budget': float(self.quarterly_budget) if self.quarterly_budget else None, - 'yearly_budget': float(self.yearly_budget) if self.yearly_budget else None, - 'budget_threshold_percent': self.budget_threshold_percent, - 'requires_receipt': self.requires_receipt, - 'requires_approval': self.requires_approval, - 'default_tax_rate': float(self.default_tax_rate) if self.default_tax_rate else None, - 'is_active': self.is_active, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "name": self.name, + "description": self.description, + "code": self.code, + "color": self.color, + "icon": self.icon, + "monthly_budget": float(self.monthly_budget) if self.monthly_budget else None, + "quarterly_budget": float(self.quarterly_budget) if self.quarterly_budget else None, + "yearly_budget": float(self.yearly_budget) if self.yearly_budget else None, + "budget_threshold_percent": self.budget_threshold_percent, + "requires_receipt": self.requires_receipt, + "requires_approval": self.requires_approval, + "default_tax_rate": float(self.default_tax_rate) if self.default_tax_rate else None, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_active_categories(cls): """Get all active categories""" return cls.query.filter_by(is_active=True).order_by(cls.name).all() - + @classmethod - def get_categories_over_budget(cls, period='monthly'): + def get_categories_over_budget(cls, period="monthly"): """Get categories that are over their budget threshold""" categories = cls.get_active_categories() over_budget = [] - + for category in categories: utilization = category.get_budget_utilization(period) - if utilization and utilization['over_threshold']: - over_budget.append({ - 'category': category, - 'utilization': utilization - }) - - return over_budget + if utilization and utilization["over_threshold"]: + over_budget.append({"category": category, "utilization": utilization}) + return over_budget diff --git a/app/models/extra_good.py b/app/models/extra_good.py index 625f58a0..7db92caa 100644 --- a/app/models/extra_good.py +++ b/app/models/extra_good.py @@ -5,50 +5,62 @@ class ExtraGood(db.Model): """Extra Good model for tracking additional products/goods on projects and invoices""" - - __tablename__ = 'extra_goods' - + + __tablename__ = "extra_goods" + id = db.Column(db.Integer, primary_key=True) - + # Link to either project or invoice (can be both) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=True, index=True) - + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=True, index=True) + # Good details name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) category = db.Column(db.String(50), nullable=False) # 'product', 'service', 'material', 'license', 'other' - + # Pricing quantity = db.Column(db.Numeric(10, 2), nullable=False, default=1) unit_price = db.Column(db.Numeric(10, 2), nullable=False) total_amount = db.Column(db.Numeric(10, 2), nullable=False) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Billing and tracking billable = db.Column(db.Boolean, default=True, nullable=False) sku = db.Column(db.String(100), nullable=True) # Stock Keeping Unit / Product Code - + # Inventory integration - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True) - + stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True) + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Relationships - stock_item = db.relationship('StockItem', foreign_keys=[stock_item_id], lazy='joined') + stock_item = db.relationship("StockItem", foreign_keys=[stock_item_id], lazy="joined") created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships # project and invoice relationships defined via backref - creator = db.relationship('User', backref='extra_goods', foreign_keys=[created_by]) - - def __init__(self, name, unit_price, quantity=1, created_by=None, project_id=None, - invoice_id=None, description=None, category='product', billable=True, - sku=None, currency_code='EUR', stock_item_id=None): + creator = db.relationship("User", backref="extra_goods", foreign_keys=[created_by]) + + def __init__( + self, + name, + unit_price, + quantity=1, + created_by=None, + project_id=None, + invoice_id=None, + description=None, + category="product", + billable=True, + sku=None, + currency_code="EUR", + stock_item_id=None, + ): """Initialize an ExtraGood instance. - + Args: name: Name of the good/product unit_price: Price per unit @@ -75,92 +87,85 @@ def __init__(self, name, unit_price, quantity=1, created_by=None, project_id=Non self.created_by = created_by self.project_id = project_id self.invoice_id = invoice_id - + def __repr__(self): - return f'' - + return f"" + def update_total(self): """Recalculate total amount based on quantity and unit price""" self.total_amount = self.quantity * self.unit_price self.updated_at = datetime.utcnow() - + def to_dict(self): """Convert extra good to dictionary for API responses""" return { - 'id': self.id, - 'project_id': self.project_id, - 'invoice_id': self.invoice_id, - 'name': self.name, - 'description': self.description, - 'category': self.category, - 'quantity': float(self.quantity), - 'unit_price': float(self.unit_price), - 'total_amount': float(self.total_amount), - 'currency_code': self.currency_code, - 'billable': self.billable, - 'sku': self.sku, - 'stock_item_id': self.stock_item_id, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'creator': self.creator.username if self.creator else None + "id": self.id, + "project_id": self.project_id, + "invoice_id": self.invoice_id, + "name": self.name, + "description": self.description, + "category": self.category, + "quantity": float(self.quantity), + "unit_price": float(self.unit_price), + "total_amount": float(self.total_amount), + "currency_code": self.currency_code, + "billable": self.billable, + "sku": self.sku, + "stock_item_id": self.stock_item_id, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "creator": self.creator.username if self.creator else None, } - + @classmethod def get_project_goods(cls, project_id, billable_only=False): """Get all extra goods for a specific project""" query = cls.query.filter_by(project_id=project_id) - + if billable_only: query = query.filter_by(billable=True) - + return query.order_by(cls.created_at.desc()).all() - + @classmethod def get_invoice_goods(cls, invoice_id): """Get all extra goods for a specific invoice""" return cls.query.filter_by(invoice_id=invoice_id).order_by(cls.created_at.desc()).all() - + @classmethod def get_total_amount(cls, project_id=None, invoice_id=None, billable_only=False): """Calculate total amount for goods with optional filters""" query = db.session.query(db.func.sum(cls.total_amount)) - + if project_id: query = query.filter_by(project_id=project_id) - + if invoice_id: query = query.filter_by(invoice_id=invoice_id) - + if billable_only: query = query.filter_by(billable=True) - - total = query.scalar() or Decimal('0') + + total = query.scalar() or Decimal("0") return float(total) - + @classmethod def get_goods_by_category(cls, project_id=None, invoice_id=None): """Get goods grouped by category""" query = db.session.query( - cls.category, - db.func.sum(cls.total_amount).label('total_amount'), - db.func.count(cls.id).label('count') + cls.category, db.func.sum(cls.total_amount).label("total_amount"), db.func.count(cls.id).label("count") ) - + if project_id: query = query.filter_by(project_id=project_id) - + if invoice_id: query = query.filter_by(invoice_id=invoice_id) - + results = query.group_by(cls.category).all() - + return [ - { - 'category': category, - 'total_amount': float(total_amount), - 'count': count - } + {"category": category, "total_amount": float(total_amount), "count": count} for category, total_amount, count in results ] - diff --git a/app/models/focus_session.py b/app/models/focus_session.py index 30e5a839..2232cece 100644 --- a/app/models/focus_session.py +++ b/app/models/focus_session.py @@ -9,13 +9,13 @@ class FocusSession(db.Model): provide summaries independent of raw time entries. """ - __tablename__ = 'focus_sessions' + __tablename__ = "focus_sessions" id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) - time_entry_id = db.Column(db.Integer, db.ForeignKey('time_entries.id'), nullable=True, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) + time_entry_id = db.Column(db.Integer, db.ForeignKey("time_entries.id"), nullable=True, index=True) # Session timing started_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) @@ -37,22 +37,20 @@ class FocusSession(db.Model): def to_dict(self): return { - 'id': self.id, - 'user_id': self.user_id, - 'project_id': self.project_id, - 'task_id': self.task_id, - 'time_entry_id': self.time_entry_id, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'ended_at': self.ended_at.isoformat() if self.ended_at else None, - 'pomodoro_length': self.pomodoro_length, - 'short_break_length': self.short_break_length, - 'long_break_length': self.long_break_length, - 'long_break_interval': self.long_break_interval, - 'cycles_completed': self.cycles_completed, - 'interruptions': self.interruptions, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "task_id": self.task_id, + "time_entry_id": self.time_entry_id, + "started_at": self.started_at.isoformat() if self.started_at else None, + "ended_at": self.ended_at.isoformat() if self.ended_at else None, + "pomodoro_length": self.pomodoro_length, + "short_break_length": self.short_break_length, + "long_break_length": self.long_break_length, + "long_break_interval": self.long_break_interval, + "cycles_completed": self.cycles_completed, + "interruptions": self.interruptions, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - - diff --git a/app/models/import_export.py b/app/models/import_export.py index c1ee965e..0ed5c10e 100644 --- a/app/models/import_export.py +++ b/app/models/import_export.py @@ -1,20 +1,23 @@ """ Import/Export tracking models for data import/export operations """ + from datetime import datetime from app import db class DataImport(db.Model): """Model to track import operations""" - - __tablename__ = 'data_imports' - + + __tablename__ = "data_imports" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) import_type = db.Column(db.String(50), nullable=False) # 'csv', 'toggl', 'harvest', 'backup' source_file = db.Column(db.String(500), nullable=True) # Original filename - status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed', 'partial' + status = db.Column( + db.String(20), default="pending", nullable=False + ) # 'pending', 'processing', 'completed', 'failed', 'partial' total_records = db.Column(db.Integer, default=0) successful_records = db.Column(db.Integer, default=0) failed_records = db.Column(db.Integer, default=0) @@ -22,156 +25,160 @@ class DataImport(db.Model): import_summary = db.Column(db.Text, nullable=True) # JSON string with details started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) completed_at = db.Column(db.DateTime, nullable=True) - + # Relationship - user = db.relationship('User', backref=db.backref('imports', lazy='dynamic')) - + user = db.relationship("User", backref=db.backref("imports", lazy="dynamic")) + def __init__(self, user_id, import_type, source_file=None): self.user_id = user_id self.import_type = import_type self.source_file = source_file - self.status = 'pending' + self.status = "pending" self.total_records = 0 self.successful_records = 0 self.failed_records = 0 - + def __repr__(self): - return f'' - + return f"" + def start_processing(self): """Mark import as processing""" - self.status = 'processing' + self.status = "processing" db.session.commit() - + def complete(self): """Mark import as completed""" - self.status = 'completed' + self.status = "completed" self.completed_at = datetime.utcnow() db.session.commit() - + def fail(self, error_message=None): """Mark import as failed""" - self.status = 'failed' + self.status = "failed" self.completed_at = datetime.utcnow() if error_message: import json + errors = [] if self.error_log: try: errors = json.loads(self.error_log) except: pass - errors.append({'error': error_message, 'timestamp': datetime.utcnow().isoformat()}) + errors.append({"error": error_message, "timestamp": datetime.utcnow().isoformat()}) self.error_log = json.dumps(errors) db.session.commit() - + def partial_complete(self): """Mark import as partially completed (some records failed)""" - self.status = 'partial' + self.status = "partial" self.completed_at = datetime.utcnow() db.session.commit() - + def update_progress(self, total, successful, failed): """Update import progress""" self.total_records = total self.successful_records = successful self.failed_records = failed if failed > 0 and successful > 0: - self.status = 'partial' + self.status = "partial" elif failed > 0: - self.status = 'failed' + self.status = "failed" db.session.commit() - + def add_error(self, error_message, record_data=None): """Add an error to the error log""" import json + errors = [] if self.error_log: try: errors = json.loads(self.error_log) except: pass - - error_entry = { - 'error': error_message, - 'timestamp': datetime.utcnow().isoformat() - } + + error_entry = {"error": error_message, "timestamp": datetime.utcnow().isoformat()} if record_data: - error_entry['record'] = record_data - + error_entry["record"] = record_data + errors.append(error_entry) self.error_log = json.dumps(errors) db.session.commit() - + def set_summary(self, summary_dict): """Set import summary""" import json + self.import_summary = json.dumps(summary_dict) db.session.commit() - + def to_dict(self): """Convert to dictionary""" import json + return { - 'id': self.id, - 'user_id': self.user_id, - 'user': self.user.username if self.user else None, - 'import_type': self.import_type, - 'source_file': self.source_file, - 'status': self.status, - 'total_records': self.total_records, - 'successful_records': self.successful_records, - 'failed_records': self.failed_records, - 'error_log': json.loads(self.error_log) if self.error_log else [], - 'import_summary': json.loads(self.import_summary) if self.import_summary else {}, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + "id": self.id, + "user_id": self.user_id, + "user": self.user.username if self.user else None, + "import_type": self.import_type, + "source_file": self.source_file, + "status": self.status, + "total_records": self.total_records, + "successful_records": self.successful_records, + "failed_records": self.failed_records, + "error_log": json.loads(self.error_log) if self.error_log else [], + "import_summary": json.loads(self.import_summary) if self.import_summary else {}, + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, } class DataExport(db.Model): """Model to track export operations""" - - __tablename__ = 'data_exports' - + + __tablename__ = "data_exports" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) export_type = db.Column(db.String(50), nullable=False) # 'full', 'filtered', 'backup', 'gdpr' export_format = db.Column(db.String(20), nullable=False) # 'json', 'csv', 'xlsx', 'zip' file_path = db.Column(db.String(500), nullable=True) # Path to generated file file_size = db.Column(db.Integer, nullable=True) # File size in bytes - status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed' + status = db.Column( + db.String(20), default="pending", nullable=False + ) # 'pending', 'processing', 'completed', 'failed' filters = db.Column(db.Text, nullable=True) # JSON string with export filters record_count = db.Column(db.Integer, default=0) error_message = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) completed_at = db.Column(db.DateTime, nullable=True) expires_at = db.Column(db.DateTime, nullable=True) # When file should be deleted - + # Relationship - user = db.relationship('User', backref=db.backref('exports', lazy='dynamic')) - - def __init__(self, user_id, export_type, export_format='json', filters=None): + user = db.relationship("User", backref=db.backref("exports", lazy="dynamic")) + + def __init__(self, user_id, export_type, export_format="json", filters=None): self.user_id = user_id self.export_type = export_type self.export_format = export_format - self.status = 'pending' + self.status = "pending" self.record_count = 0 if filters: import json + self.filters = json.dumps(filters) - + def __repr__(self): - return f'' - + return f"" + def start_processing(self): """Mark export as processing""" - self.status = 'processing' + self.status = "processing" db.session.commit() - + def complete(self, file_path, file_size, record_count): """Mark export as completed""" - self.status = 'completed' + self.status = "completed" self.file_path = file_path self.file_size = file_size self.record_count = record_count @@ -179,42 +186,42 @@ def complete(self, file_path, file_size, record_count): # Set expiration to 7 days from now self.expires_at = datetime.utcnow() + timedelta(days=7) db.session.commit() - + def fail(self, error_message): """Mark export as failed""" - self.status = 'failed' + self.status = "failed" self.error_message = error_message self.completed_at = datetime.utcnow() db.session.commit() - + def is_expired(self): """Check if export has expired""" if not self.expires_at: return False return datetime.utcnow() > self.expires_at - + def to_dict(self): """Convert to dictionary""" import json + return { - 'id': self.id, - 'user_id': self.user_id, - 'user': self.user.username if self.user else None, - 'export_type': self.export_type, - 'export_format': self.export_format, - 'file_path': self.file_path, - 'file_size': self.file_size, - 'status': self.status, - 'filters': json.loads(self.filters) if self.filters else {}, - 'record_count': self.record_count, - 'error_message': self.error_message, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'completed_at': self.completed_at.isoformat() if self.completed_at else None, - 'expires_at': self.expires_at.isoformat() if self.expires_at else None, - 'is_expired': self.is_expired(), + "id": self.id, + "user_id": self.user_id, + "user": self.user.username if self.user else None, + "export_type": self.export_type, + "export_format": self.export_format, + "file_path": self.file_path, + "file_size": self.file_size, + "status": self.status, + "filters": json.loads(self.filters) if self.filters else {}, + "record_count": self.record_count, + "error_message": self.error_message, + "created_at": self.created_at.isoformat() if self.created_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "is_expired": self.is_expired(), } # Fix missing import from datetime import timedelta - diff --git a/app/models/integration.py b/app/models/integration.py index b24cfa80..0f0ca734 100644 --- a/app/models/integration.py +++ b/app/models/integration.py @@ -10,13 +10,13 @@ class Integration(db.Model): """Integration model for third-party service connections.""" - __tablename__ = 'integrations' - __table_args__ = {'extend_existing': True} + __tablename__ = "integrations" + __table_args__ = {"extend_existing": True} id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) # e.g., 'Jira', 'Slack', 'GitHub' provider = db.Column(db.String(50), nullable=False, index=True) # e.g., 'jira', 'slack', 'github' - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) is_active = db.Column(db.Boolean, default=False, nullable=False) # Only True when credentials are set up config = db.Column(JSON, nullable=True) # Provider-specific configuration last_sync_at = db.Column(db.DateTime, nullable=True) @@ -25,7 +25,7 @@ class Integration(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - user = db.relationship('User', backref='integrations') + user = db.relationship("User", backref="integrations") def __repr__(self): return f"" @@ -34,21 +34,23 @@ def __repr__(self): class IntegrationCredential(db.Model): """Stores OAuth tokens and credentials for integrations.""" - __tablename__ = 'integration_credentials' - __table_args__ = {'extend_existing': True} + __tablename__ = "integration_credentials" + __table_args__ = {"extend_existing": True} id = db.Column(db.Integer, primary_key=True) - integration_id = db.Column(db.Integer, db.ForeignKey('integrations.id', ondelete='CASCADE'), nullable=False, index=True) + integration_id = db.Column( + db.Integer, db.ForeignKey("integrations.id", ondelete="CASCADE"), nullable=False, index=True + ) access_token = db.Column(db.Text, nullable=True) # Encrypted in production refresh_token = db.Column(db.Text, nullable=True) # Encrypted in production - token_type = db.Column(db.String(20), default='Bearer', nullable=False) + token_type = db.Column(db.String(20), default="Bearer", nullable=False) expires_at = db.Column(db.DateTime, nullable=True) scope = db.Column(db.String(500), nullable=True) # OAuth scopes extra_data = db.Column(JSON, nullable=True) # Additional provider-specific data created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - integration = db.relationship('Integration', backref=db.backref('credentials', cascade='all, delete-orphan')) + integration = db.relationship("Integration", backref=db.backref("credentials", cascade="all, delete-orphan")) def __repr__(self): return f"" @@ -65,25 +67,29 @@ def needs_refresh(self): if not self.expires_at or not self.refresh_token: return False from datetime import timedelta + return datetime.utcnow() >= (self.expires_at - timedelta(minutes=5)) class IntegrationEvent(db.Model): """Tracks integration events and sync history.""" - __tablename__ = 'integration_events' - __table_args__ = {'extend_existing': True} + __tablename__ = "integration_events" + __table_args__ = {"extend_existing": True} id = db.Column(db.Integer, primary_key=True) - integration_id = db.Column(db.Integer, db.ForeignKey('integrations.id', ondelete='CASCADE'), nullable=False, index=True) + integration_id = db.Column( + db.Integer, db.ForeignKey("integrations.id", ondelete="CASCADE"), nullable=False, index=True + ) event_type = db.Column(db.String(50), nullable=False) # 'sync', 'webhook', 'error', etc. status = db.Column(db.String(20), nullable=False) # 'success', 'error', 'pending' message = db.Column(db.Text, nullable=True) - event_metadata = db.Column(JSON, nullable=True) # Event-specific data (renamed from 'metadata' to avoid SQLAlchemy conflict) + event_metadata = db.Column( + JSON, nullable=True + ) # Event-specific data (renamed from 'metadata' to avoid SQLAlchemy conflict) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) - integration = db.relationship('Integration', backref='events') + integration = db.relationship("Integration", backref="events") def __repr__(self): return f"" - diff --git a/app/models/invoice.py b/app/models/invoice.py index dda56eef..546b9600 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -2,64 +2,73 @@ from decimal import Decimal from app import db + class Invoice(db.Model): """Invoice model for client billing""" - - __tablename__ = 'invoices' - + + __tablename__ = "invoices" + id = db.Column(db.Integer, primary_key=True) invoice_number = db.Column(db.String(50), unique=True, nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) client_name = db.Column(db.String(200), nullable=False) client_email = db.Column(db.String(200), nullable=True) client_address = db.Column(db.Text, nullable=True) # Link to clients table (enforced by DB schema) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) - quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id'), nullable=True, index=True) - + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True) + quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id"), nullable=True, index=True) + # Invoice details issue_date = db.Column(db.Date, nullable=False, default=datetime.utcnow().date) due_date = db.Column(db.Date, nullable=False) - status = db.Column(db.String(20), default='draft', nullable=False) # 'draft', 'sent', 'paid', 'overdue', 'cancelled' - + status = db.Column( + db.String(20), default="draft", nullable=False + ) # 'draft', 'sent', 'paid', 'overdue', 'cancelled' + # Billing information subtotal = db.Column(db.Numeric(10, 2), nullable=False, default=0) tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0) # Percentage tax_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - template_id = db.Column(db.Integer, db.ForeignKey('invoice_templates.id'), nullable=True, index=True) - recurring_invoice_id = db.Column(db.Integer, db.ForeignKey('recurring_invoices.id'), nullable=True, index=True) - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + template_id = db.Column(db.Integer, db.ForeignKey("invoice_templates.id"), nullable=True, index=True) + recurring_invoice_id = db.Column(db.Integer, db.ForeignKey("recurring_invoices.id"), nullable=True, index=True) + # Notes and terms notes = db.Column(db.Text, nullable=True) terms = db.Column(db.Text, nullable=True) - + # Payment tracking payment_date = db.Column(db.Date, nullable=True) - payment_method = db.Column(db.String(50), nullable=True) # 'cash', 'check', 'bank_transfer', 'credit_card', 'paypal', etc. + payment_method = db.Column( + db.String(50), nullable=True + ) # 'cash', 'check', 'bank_transfer', 'credit_card', 'paypal', etc. payment_reference = db.Column(db.String(100), nullable=True) # Transaction ID, check number, etc. payment_notes = db.Column(db.Text, nullable=True) amount_paid = db.Column(db.Numeric(10, 2), nullable=True, default=0) - payment_status = db.Column(db.String(20), nullable=False, default='unpaid') # 'unpaid', 'partially_paid', 'fully_paid', 'overpaid' - + payment_status = db.Column( + db.String(20), nullable=False, default="unpaid" + ) # 'unpaid', 'partially_paid', 'fully_paid', 'overpaid' + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - project = db.relationship('Project', backref='invoices') - client = db.relationship('Client', backref='invoices') - quote = db.relationship('Quote', backref='invoices') - creator = db.relationship('User', backref='created_invoices') - items = db.relationship('InvoiceItem', backref='invoice', lazy='dynamic', cascade='all, delete-orphan') - payments = db.relationship('Payment', backref='invoice', lazy='dynamic', cascade='all, delete-orphan') - credits = db.relationship('CreditNote', backref='invoice', lazy='dynamic', cascade='all, delete-orphan') - reminder_schedules = db.relationship('InvoiceReminderSchedule', backref='invoice', lazy='dynamic', cascade='all, delete-orphan') - template = db.relationship('InvoiceTemplate', backref='invoices', lazy='joined') - extra_goods = db.relationship('ExtraGood', backref='invoice', lazy='dynamic', cascade='all, delete-orphan') - + project = db.relationship("Project", backref="invoices") + client = db.relationship("Client", backref="invoices") + quote = db.relationship("Quote", backref="invoices") + creator = db.relationship("User", backref="created_invoices") + items = db.relationship("InvoiceItem", backref="invoice", lazy="dynamic", cascade="all, delete-orphan") + payments = db.relationship("Payment", backref="invoice", lazy="dynamic", cascade="all, delete-orphan") + credits = db.relationship("CreditNote", backref="invoice", lazy="dynamic", cascade="all, delete-orphan") + reminder_schedules = db.relationship( + "InvoiceReminderSchedule", backref="invoice", lazy="dynamic", cascade="all, delete-orphan" + ) + template = db.relationship("InvoiceTemplate", backref="invoices", lazy="joined") + extra_goods = db.relationship("ExtraGood", backref="invoice", lazy="dynamic", cascade="all, delete-orphan") + def __init__(self, invoice_number, project_id, client_name, due_date, created_by, client_id, **kwargs): self.invoice_number = invoice_number self.project_id = project_id @@ -67,102 +76,104 @@ def __init__(self, invoice_number, project_id, client_name, due_date, created_by self.due_date = due_date self.created_by = created_by self.client_id = client_id - self.quote_id = kwargs.get('quote_id') - + self.quote_id = kwargs.get("quote_id") + # Set optional fields - self.client_email = kwargs.get('client_email') - self.client_address = kwargs.get('client_address') - self.issue_date = kwargs.get('issue_date', datetime.utcnow().date()) - self.notes = kwargs.get('notes') - self.terms = kwargs.get('terms') - self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0))) - self.currency_code = kwargs.get('currency_code') or self.currency_code - self.template_id = kwargs.get('template_id') if kwargs.get('template_id') else None - + self.client_email = kwargs.get("client_email") + self.client_address = kwargs.get("client_address") + self.issue_date = kwargs.get("issue_date", datetime.utcnow().date()) + self.notes = kwargs.get("notes") + self.terms = kwargs.get("terms") + self.tax_rate = Decimal(str(kwargs.get("tax_rate", 0))) + self.currency_code = kwargs.get("currency_code") or self.currency_code + self.template_id = kwargs.get("template_id") if kwargs.get("template_id") else None + # Set payment tracking fields - self.payment_date = kwargs.get('payment_date') - self.payment_method = kwargs.get('payment_method') - self.payment_reference = kwargs.get('payment_reference') - self.payment_notes = kwargs.get('payment_notes') - self.amount_paid = Decimal(str(kwargs.get('amount_paid', 0))) - self.payment_status = kwargs.get('payment_status', 'unpaid') - + self.payment_date = kwargs.get("payment_date") + self.payment_method = kwargs.get("payment_method") + self.payment_reference = kwargs.get("payment_reference") + self.payment_notes = kwargs.get("payment_notes") + self.amount_paid = Decimal(str(kwargs.get("amount_paid", 0))) + self.payment_status = kwargs.get("payment_status", "unpaid") + def __repr__(self): - return f'' - + return f"" + @property def is_overdue(self): """Check if invoice is overdue""" - return self.status in ['sent', 'overdue'] and datetime.utcnow().date() > self.due_date - + return self.status in ["sent", "overdue"] and datetime.utcnow().date() > self.due_date + @property def days_overdue(self): """Calculate days overdue""" if not self.is_overdue: return 0 return (datetime.utcnow().date() - self.due_date).days - + @property def is_paid(self): """Check if invoice is fully paid""" - return self.payment_status == 'fully_paid' - + return self.payment_status == "fully_paid" + @property def is_partially_paid(self): """Check if invoice is partially paid""" - return self.payment_status == 'partially_paid' - + return self.payment_status == "partially_paid" + @property def outstanding_amount(self): """Calculate outstanding amount""" - credits_total = sum((c.amount for c in self.credits), Decimal('0')) if self.credits else Decimal('0') + credits_total = sum((c.amount for c in self.credits), Decimal("0")) if self.credits else Decimal("0") return self.total_amount - (self.amount_paid or 0) - credits_total - + @property def payment_percentage(self): """Calculate payment percentage""" if self.total_amount == 0: return 0 return float((self.amount_paid or 0) / self.total_amount * 100) - + @property def sorted_payments(self): """Get payments sorted by payment_date and created_at (newest first)""" from app.models.payments import Payment + return self.payments.order_by(Payment.payment_date.desc(), Payment.created_at.desc()).all() - + def update_payment_status(self): """Update payment status based on amount paid""" if not self.amount_paid or self.amount_paid == 0: - self.payment_status = 'unpaid' + self.payment_status = "unpaid" elif self.amount_paid >= self.total_amount: if self.amount_paid > self.total_amount: - self.payment_status = 'overpaid' + self.payment_status = "overpaid" else: - self.payment_status = 'fully_paid' + self.payment_status = "fully_paid" else: - self.payment_status = 'partially_paid' - - def record_payment(self, amount, payment_date=None, payment_method=None, payment_reference=None, payment_notes=None): + self.payment_status = "partially_paid" + + def record_payment( + self, amount, payment_date=None, payment_method=None, payment_reference=None, payment_notes=None + ): """ DEPRECATED: Record a payment for this invoice. - + This method is deprecated. Please use the Payment model (app.models.Payment) to record payments instead. The Payment model provides: - Multiple payment tracking per invoice - Payment status management (completed, pending, failed, refunded) - Gateway fee tracking - Better audit trail - + This method is kept for backwards compatibility only and may be removed in a future version. """ import warnings + warnings.warn( - "Invoice.record_payment() is deprecated. Use the Payment model instead.", - DeprecationWarning, - stacklevel=2 + "Invoice.record_payment() is deprecated. Use the Payment model instead.", DeprecationWarning, stacklevel=2 ) - + self.amount_paid = (self.amount_paid or 0) + Decimal(str(amount)) self.payment_date = payment_date or datetime.utcnow().date() if payment_method: @@ -171,17 +182,17 @@ def record_payment(self, amount, payment_date=None, payment_method=None, payment self.payment_reference = payment_reference if payment_notes: self.payment_notes = payment_notes - + self.update_payment_status() - + # Update invoice status based on payment - if self.payment_status == 'fully_paid': - self.status = 'paid' - elif self.payment_status in ['partially_paid', 'overpaid']: + if self.payment_status == "fully_paid": + self.status = "paid" + elif self.payment_status in ["partially_paid", "overpaid"]: # Keep current status but ensure it's not 'paid' if only partially paid - if self.payment_status == 'partially_paid' and self.status == 'paid': - self.status = 'sent' - + if self.payment_status == "partially_paid" and self.status == "paid": + self.status = "sent" + def calculate_totals(self): """Calculate invoice totals from items, extra goods, and expenses""" # Optionally apply tax rules before totals @@ -196,10 +207,10 @@ def calculate_totals(self): self.subtotal = subtotal self.tax_amount = subtotal * (self.tax_rate / 100) self.total_amount = subtotal + self.tax_amount - + # Update status if overdue - if self.status == 'sent' and self.is_overdue: - self.status = 'overdue' + if self.status == "sent" and self.is_overdue: + self.status = "overdue" def _apply_tax_rules_if_any(self): """Apply matching tax rule to set `tax_rate` if applicable. @@ -207,6 +218,7 @@ def _apply_tax_rules_if_any(self): """ try: from .tax_rule import TaxRule # local import to avoid circular + today = self.issue_date or datetime.utcnow().date() query = TaxRule.query.filter(TaxRule.active == True) # constrain by date range @@ -230,102 +242,106 @@ def _apply_tax_rules_if_any(self): except Exception: # Best-effort only pass - + def to_dict(self): """Convert invoice to dictionary for API responses""" return { - 'id': self.id, - 'invoice_number': self.invoice_number, - 'project_id': self.project_id, - 'client_name': self.client_name, - 'client_email': self.client_email, - 'client_address': self.client_address, - 'client_id': self.client_id, - 'quote_id': self.quote_id, - 'issue_date': self.issue_date.isoformat() if self.issue_date else None, - 'due_date': self.due_date.isoformat() if self.due_date else None, - 'status': self.status, - 'subtotal': float(self.subtotal), - 'tax_rate': float(self.tax_rate), - 'tax_amount': float(self.tax_amount), - 'total_amount': float(self.total_amount), - 'notes': self.notes, - 'terms': self.terms, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'is_overdue': self.is_overdue, - 'days_overdue': self.days_overdue, + "id": self.id, + "invoice_number": self.invoice_number, + "project_id": self.project_id, + "client_name": self.client_name, + "client_email": self.client_email, + "client_address": self.client_address, + "client_id": self.client_id, + "quote_id": self.quote_id, + "issue_date": self.issue_date.isoformat() if self.issue_date else None, + "due_date": self.due_date.isoformat() if self.due_date else None, + "status": self.status, + "subtotal": float(self.subtotal), + "tax_rate": float(self.tax_rate), + "tax_amount": float(self.tax_amount), + "total_amount": float(self.total_amount), + "notes": self.notes, + "terms": self.terms, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "is_overdue": self.is_overdue, + "days_overdue": self.days_overdue, # Payment tracking fields - 'payment_date': self.payment_date.isoformat() if self.payment_date else None, - 'payment_method': self.payment_method, - 'payment_reference': self.payment_reference, - 'payment_notes': self.payment_notes, - 'amount_paid': float(self.amount_paid) if self.amount_paid else 0, - 'payment_status': self.payment_status, - 'is_paid': self.is_paid, - 'is_partially_paid': self.is_partially_paid, - 'outstanding_amount': float(self.outstanding_amount), - 'payment_percentage': self.payment_percentage + "payment_date": self.payment_date.isoformat() if self.payment_date else None, + "payment_method": self.payment_method, + "payment_reference": self.payment_reference, + "payment_notes": self.payment_notes, + "amount_paid": float(self.amount_paid) if self.amount_paid else 0, + "payment_status": self.payment_status, + "is_paid": self.is_paid, + "is_partially_paid": self.is_partially_paid, + "outstanding_amount": float(self.outstanding_amount), + "payment_percentage": self.payment_percentage, } - + @classmethod def generate_invoice_number(cls): """Generate a unique invoice number""" from datetime import datetime - + # Format: INV-YYYYMMDD-XXX today = datetime.utcnow() - date_prefix = today.strftime('%Y%m%d') - + date_prefix = today.strftime("%Y%m%d") + # Find the next available number for today - existing = cls.query.filter( - cls.invoice_number.like(f'INV-{date_prefix}-%') - ).order_by(cls.invoice_number.desc()).first() - + existing = ( + cls.query.filter(cls.invoice_number.like(f"INV-{date_prefix}-%")) + .order_by(cls.invoice_number.desc()) + .first() + ) + if existing: # Extract the number part and increment try: - last_num = int(existing.invoice_number.split('-')[-1]) + last_num = int(existing.invoice_number.split("-")[-1]) next_num = last_num + 1 except (ValueError, IndexError): next_num = 1 else: next_num = 1 - - return f'INV-{date_prefix}-{next_num:03d}' + + return f"INV-{date_prefix}-{next_num:03d}" class InvoiceItem(db.Model): """Invoice line item model""" - - __tablename__ = 'invoice_items' - + + __tablename__ = "invoice_items" + id = db.Column(db.Integer, primary_key=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) - + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=False, index=True) + # Item details description = db.Column(db.String(500), nullable=False) quantity = db.Column(db.Numeric(10, 2), nullable=False, default=1) # Hours unit_price = db.Column(db.Numeric(10, 2), nullable=False) # Hourly rate total_amount = db.Column(db.Numeric(10, 2), nullable=False) - + # Time entry reference (optional) time_entry_ids = db.Column(db.String(500), nullable=True) # Comma-separated IDs - + # Inventory integration - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True) - warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=True) + stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True) + warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=True) is_stock_item = db.Column(db.Boolean, default=False, nullable=False) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - + # Relationships - stock_item = db.relationship('StockItem', foreign_keys=[stock_item_id], lazy='joined') - warehouse = db.relationship('Warehouse', foreign_keys=[warehouse_id], lazy='joined') - - def __init__(self, invoice_id, description, quantity, unit_price, time_entry_ids=None, stock_item_id=None, warehouse_id=None): + stock_item = db.relationship("StockItem", foreign_keys=[stock_item_id], lazy="joined") + warehouse = db.relationship("Warehouse", foreign_keys=[warehouse_id], lazy="joined") + + def __init__( + self, invoice_id, description, quantity, unit_price, time_entry_ids=None, stock_item_id=None, warehouse_id=None + ): self.invoice_id = invoice_id self.description = description self.quantity = Decimal(str(quantity)) @@ -335,22 +351,22 @@ def __init__(self, invoice_id, description, quantity, unit_price, time_entry_ids self.stock_item_id = stock_item_id self.warehouse_id = warehouse_id self.is_stock_item = stock_item_id is not None - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert invoice item to dictionary""" return { - 'id': self.id, - 'invoice_id': self.invoice_id, - 'description': self.description, - 'quantity': float(self.quantity), - 'unit_price': float(self.unit_price), - 'total_amount': float(self.total_amount), - 'time_entry_ids': self.time_entry_ids, - 'stock_item_id': self.stock_item_id, - 'warehouse_id': self.warehouse_id, - 'is_stock_item': self.is_stock_item, - 'created_at': self.created_at.isoformat() if self.created_at else None + "id": self.id, + "invoice_id": self.invoice_id, + "description": self.description, + "quantity": float(self.quantity), + "unit_price": float(self.unit_price), + "total_amount": float(self.total_amount), + "time_entry_ids": self.time_entry_ids, + "stock_item_id": self.stock_item_id, + "warehouse_id": self.warehouse_id, + "is_stock_item": self.is_stock_item, + "created_at": self.created_at.isoformat() if self.created_at else None, } diff --git a/app/models/invoice_approval.py b/app/models/invoice_approval.py index 7d9d2885..a8ea4bf2 100644 --- a/app/models/invoice_approval.py +++ b/app/models/invoice_approval.py @@ -1,84 +1,84 @@ """Invoice approval workflow models""" + from datetime import datetime from app import db class InvoiceApproval(db.Model): """Invoice approval workflow tracking""" - - __tablename__ = 'invoice_approvals' - + + __tablename__ = "invoice_approvals" + id = db.Column(db.Integer, primary_key=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) - + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=False, index=True) + # Approval workflow - status = db.Column(db.String(20), default='pending', nullable=False, index=True) + status = db.Column(db.String(20), default="pending", nullable=False, index=True) # Status: 'pending', 'approved', 'rejected', 'cancelled' - + # Approval stages (JSON array) # Each stage: {stage_number, approver_id, status, comments, approved_at, rejected_at} stages = db.Column(db.JSON, nullable=False, default=list) - + # Current stage current_stage = db.Column(db.Integer, default=0, nullable=False) total_stages = db.Column(db.Integer, default=1, nullable=False) - + # Requester - requested_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + requested_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) requested_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - + # Final approval/rejection - approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) approved_at = db.Column(db.DateTime, nullable=True) - rejected_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + rejected_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) rejected_at = db.Column(db.DateTime, nullable=True) rejection_reason = db.Column(db.Text, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - invoice = db.relationship('Invoice', backref='approvals') - requester = db.relationship('User', foreign_keys=[requested_by], backref='requested_approvals') - approver = db.relationship('User', foreign_keys=[approved_by], backref='approved_invoices') - rejector = db.relationship('User', foreign_keys=[rejected_by], backref='rejected_invoices') - + invoice = db.relationship("Invoice", backref="approvals") + requester = db.relationship("User", foreign_keys=[requested_by], backref="requested_approvals") + approver = db.relationship("User", foreign_keys=[approved_by], backref="approved_invoices") + rejector = db.relationship("User", foreign_keys=[rejected_by], backref="rejected_invoices") + def __repr__(self): - return f'' - + return f"" + @property def is_pending(self): """Check if approval is pending""" - return self.status == 'pending' - + return self.status == "pending" + @property def is_approved(self): """Check if approval is approved""" - return self.status == 'approved' - + return self.status == "approved" + @property def is_rejected(self): """Check if approval is rejected""" - return self.status == 'rejected' - + return self.status == "rejected" + def to_dict(self): """Convert approval to dictionary""" return { - 'id': self.id, - 'invoice_id': self.invoice_id, - 'status': self.status, - 'stages': self.stages or [], - 'current_stage': self.current_stage, - 'total_stages': self.total_stages, - 'requested_by': self.requested_by, - 'requested_at': self.requested_at.isoformat() if self.requested_at else None, - 'approved_by': self.approved_by, - 'approved_at': self.approved_at.isoformat() if self.approved_at else None, - 'rejected_by': self.rejected_by, - 'rejected_at': self.rejected_at.isoformat() if self.rejected_at else None, - 'rejection_reason': self.rejection_reason, - 'created_at': self.created_at.isoformat(), - 'updated_at': self.updated_at.isoformat() + "id": self.id, + "invoice_id": self.invoice_id, + "status": self.status, + "stages": self.stages or [], + "current_stage": self.current_stage, + "total_stages": self.total_stages, + "requested_by": self.requested_by, + "requested_at": self.requested_at.isoformat() if self.requested_at else None, + "approved_by": self.approved_by, + "approved_at": self.approved_at.isoformat() if self.approved_at else None, + "rejected_by": self.rejected_by, + "rejected_at": self.rejected_at.isoformat() if self.rejected_at else None, + "rejection_reason": self.rejection_reason, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), } - diff --git a/app/models/invoice_email.py b/app/models/invoice_email.py index 53a20af5..1bad230c 100644 --- a/app/models/invoice_email.py +++ b/app/models/invoice_email.py @@ -10,91 +10,90 @@ def local_now(): class InvoiceEmail(db.Model): """Model for tracking invoice emails sent to clients""" - - __tablename__ = 'invoice_emails' - + + __tablename__ = "invoice_emails" + id = db.Column(db.Integer, primary_key=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) - + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=False, index=True) + # Email details recipient_email = db.Column(db.String(200), nullable=False) subject = db.Column(db.String(500), nullable=False) sent_at = db.Column(db.DateTime, nullable=False, default=local_now) - sent_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - + sent_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + # Tracking opened_at = db.Column(db.DateTime, nullable=True) # When email was opened (if tracked) opened_count = db.Column(db.Integer, nullable=False, default=0) # Number of times opened last_opened_at = db.Column(db.DateTime, nullable=True) # Last time email was opened - + # Payment tracking paid_at = db.Column(db.DateTime, nullable=True) # When invoice was marked as paid (if after email) - + # Status - status = db.Column(db.String(20), nullable=False, default='sent') # 'sent', 'opened', 'paid', 'bounced', 'failed' - + status = db.Column(db.String(20), nullable=False, default="sent") # 'sent', 'opened', 'paid', 'bounced', 'failed' + # Error tracking error_message = db.Column(db.Text, nullable=True) # Error message if send failed - + # Metadata created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Relationships - invoice = db.relationship('Invoice', backref='email_records') - sender = db.relationship('User', backref='sent_invoice_emails') - + invoice = db.relationship("Invoice", backref="email_records") + sender = db.relationship("User", backref="sent_invoice_emails") + def __init__(self, invoice_id, recipient_email, subject, sent_by, **kwargs): self.invoice_id = invoice_id self.recipient_email = recipient_email self.subject = subject self.sent_by = sent_by - self.status = kwargs.get('status', 'sent') - self.error_message = kwargs.get('error_message') - + self.status = kwargs.get("status", "sent") + self.error_message = kwargs.get("error_message") + def __repr__(self): - return f' {self.recipient_email} ({self.status})>' - + return f" {self.recipient_email} ({self.status})>" + def mark_opened(self): """Mark email as opened""" if not self.opened_at: self.opened_at = local_now() self.last_opened_at = local_now() self.opened_count += 1 - if self.status == 'sent': - self.status = 'opened' - + if self.status == "sent": + self.status = "opened" + def mark_paid(self): """Mark invoice as paid (after email was sent)""" if not self.paid_at: self.paid_at = local_now() - self.status = 'paid' - + self.status = "paid" + def mark_failed(self, error_message): """Mark email send as failed""" - self.status = 'failed' + self.status = "failed" self.error_message = error_message - + def mark_bounced(self): """Mark email as bounced""" - self.status = 'bounced' - + self.status = "bounced" + def to_dict(self): """Convert invoice email to dictionary""" return { - 'id': self.id, - 'invoice_id': self.invoice_id, - 'recipient_email': self.recipient_email, - 'subject': self.subject, - 'sent_at': self.sent_at.isoformat() if self.sent_at else None, - 'sent_by': self.sent_by, - 'opened_at': self.opened_at.isoformat() if self.opened_at else None, - 'opened_count': self.opened_count, - 'last_opened_at': self.last_opened_at.isoformat() if self.last_opened_at else None, - 'paid_at': self.paid_at.isoformat() if self.paid_at else None, - 'status': self.status, - 'error_message': self.error_message, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "invoice_id": self.invoice_id, + "recipient_email": self.recipient_email, + "subject": self.subject, + "sent_at": self.sent_at.isoformat() if self.sent_at else None, + "sent_by": self.sent_by, + "opened_at": self.opened_at.isoformat() if self.opened_at else None, + "opened_count": self.opened_count, + "last_opened_at": self.last_opened_at.isoformat() if self.last_opened_at else None, + "paid_at": self.paid_at.isoformat() if self.paid_at else None, + "status": self.status, + "error_message": self.error_message, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - diff --git a/app/models/invoice_pdf_template.py b/app/models/invoice_pdf_template.py index 00600862..312f1aa0 100644 --- a/app/models/invoice_pdf_template.py +++ b/app/models/invoice_pdf_template.py @@ -2,15 +2,16 @@ Stores PDF templates for different page sizes (A4, Letter, A3, etc.) """ + from datetime import datetime from app import db class InvoicePDFTemplate(db.Model): """Model for storing invoice PDF templates by page size""" - - __tablename__ = 'invoice_pdf_templates' - + + __tablename__ = "invoice_pdf_templates" + id = db.Column(db.Integer, primary_key=True) page_size = db.Column(db.String(20), nullable=False, unique=True) # A4, Letter, A3, Legal, A5, etc. template_html = db.Column(db.Text, nullable=True) @@ -19,33 +20,27 @@ class InvoicePDFTemplate(db.Model): is_default = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Standard page sizes and their dimensions in mm (for reference) PAGE_SIZES = { - 'A4': {'width': 210, 'height': 297}, - 'Letter': {'width': 216, 'height': 279}, - 'Legal': {'width': 216, 'height': 356}, - 'A3': {'width': 297, 'height': 420}, - 'A5': {'width': 148, 'height': 210}, - 'Tabloid': {'width': 279, 'height': 432}, + "A4": {"width": 210, "height": 297}, + "Letter": {"width": 216, "height": 279}, + "Legal": {"width": 216, "height": 356}, + "A3": {"width": 297, "height": 420}, + "A5": {"width": 148, "height": 210}, + "Tabloid": {"width": 279, "height": 432}, } - + def __repr__(self): - return f'' - + return f"" + @classmethod - def get_template(cls, page_size='A4'): + def get_template(cls, page_size="A4"): """Get template for a specific page size, create default if doesn't exist""" template = cls.query.filter_by(page_size=page_size).first() if not template: # Create default template for this size - template = cls( - page_size=page_size, - template_html='', - template_css='', - design_json='', - is_default=True - ) + template = cls(page_size=page_size, template_html="", template_css="", design_json="", is_default=True) db.session.add(template) try: db.session.commit() @@ -56,59 +51,52 @@ def get_template(cls, page_size='A4'): if not template: raise return template - + @classmethod def get_all_templates(cls): """Get all templates ordered by page size""" return cls.query.order_by(cls.page_size).all() - + @classmethod def get_default_template(cls): """Get the default template (A4)""" - return cls.get_template('A4') - + return cls.get_template("A4") + @classmethod def ensure_default_templates(cls): """Ensure all default templates exist""" - default_sizes = ['A4', 'Letter', 'Legal', 'A3', 'A5'] + default_sizes = ["A4", "Letter", "Legal", "A3", "A5"] for size in default_sizes: template = cls.query.filter_by(page_size=size).first() if not template: - template = cls( - page_size=size, - template_html='', - template_css='', - design_json='', - is_default=True - ) + template = cls(page_size=size, template_html="", template_css="", design_json="", is_default=True) db.session.add(template) try: db.session.commit() except Exception: db.session.rollback() - + def to_dict(self): """Convert template to dictionary""" return { - 'id': self.id, - 'page_size': self.page_size, - 'template_html': self.template_html or '', - 'template_css': self.template_css or '', - 'design_json': self.design_json or '', - 'is_default': self.is_default, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "page_size": self.page_size, + "template_html": self.template_html or "", + "template_css": self.template_css or "", + "design_json": self.design_json or "", + "is_default": self.is_default, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + def get_page_dimensions_mm(self): """Get page dimensions in mm""" - return self.PAGE_SIZES.get(self.page_size, {'width': 210, 'height': 297}) - + return self.PAGE_SIZES.get(self.page_size, {"width": 210, "height": 297}) + def get_page_dimensions_px(self, dpi=72): """Get page dimensions in pixels at given DPI""" dims_mm = self.get_page_dimensions_mm() # Convert mm to pixels: 1 mm = (dpi / 25.4) pixels - width_px = int((dims_mm['width'] / 25.4) * dpi) - height_px = int((dims_mm['height'] / 25.4) * dpi) - return {'width': width_px, 'height': height_px} - + width_px = int((dims_mm["width"] / 25.4) * dpi) + height_px = int((dims_mm["height"] / 25.4) * dpi) + return {"width": width_px, "height": height_px} diff --git a/app/models/invoice_template.py b/app/models/invoice_template.py index 0db8b45c..ab97d936 100644 --- a/app/models/invoice_template.py +++ b/app/models/invoice_template.py @@ -5,7 +5,7 @@ class InvoiceTemplate(db.Model): """Reusable invoice templates/themes with customizable HTML and CSS.""" - __tablename__ = 'invoice_templates' + __tablename__ = "invoice_templates" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False, unique=True, index=True) @@ -19,5 +19,3 @@ class InvoiceTemplate(db.Model): def __repr__(self): return f"" - - diff --git a/app/models/kanban_column.py b/app/models/kanban_column.py index c2606c53..9b1d3bec 100644 --- a/app/models/kanban_column.py +++ b/app/models/kanban_column.py @@ -1,58 +1,62 @@ from app import db from app.utils.timezone import now_in_app_timezone + class KanbanColumn(db.Model): """Model for custom Kanban board columns/task statuses""" - - __tablename__ = 'kanban_columns' - + + __tablename__ = "kanban_columns" + id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=True, index=True) # NULL = global columns + project_id = db.Column( + db.Integer, db.ForeignKey("projects.id", ondelete="CASCADE"), nullable=True, index=True + ) # NULL = global columns key = db.Column(db.String(50), nullable=False, index=True) # Internal identifier (e.g. 'in_progress') label = db.Column(db.String(100), nullable=False) # Display name (e.g. 'In Progress') - icon = db.Column(db.String(100), default='fas fa-circle') # Font Awesome icon class - color = db.Column(db.String(50), default='secondary') # Bootstrap color class or hex + icon = db.Column(db.String(100), default="fas fa-circle") # Font Awesome icon class + color = db.Column(db.String(50), default="secondary") # Bootstrap color class or hex position = db.Column(db.Integer, nullable=False, default=0, index=True) # Order in kanban board is_active = db.Column(db.Boolean, default=True, nullable=False) # Can be disabled without deletion is_system = db.Column(db.Boolean, default=False, nullable=False) # System columns cannot be deleted is_complete_state = db.Column(db.Boolean, default=False, nullable=False) # Marks task as completed created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False) - + # Unique constraint: key must be unique per project (or globally if project_id is NULL) - __table_args__ = (db.UniqueConstraint('key', 'project_id', name='uq_kanban_column_key_project'),) - + __table_args__ = (db.UniqueConstraint("key", "project_id", name="uq_kanban_column_key_project"),) + def __init__(self, **kwargs): """Initialize a new KanbanColumn""" super(KanbanColumn, self).__init__(**kwargs) - + def __repr__(self): project_info = f" project_id={self.project_id}" if self.project_id else " global" - return f'' - + return f"" + def to_dict(self): """Convert column to dictionary for API responses""" return { - 'id': self.id, - 'project_id': self.project_id, - 'key': self.key, - 'label': self.label, - 'icon': self.icon, - 'color': self.color, - 'position': self.position, - 'is_active': self.is_active, - 'is_system': self.is_system, - 'is_complete_state': self.is_complete_state, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "project_id": self.project_id, + "key": self.key, + "label": self.label, + "icon": self.icon, + "color": self.color, + "position": self.position, + "is_active": self.is_active, + "is_system": self.is_system, + "is_complete_state": self.is_complete_state, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_active_columns(cls, project_id=None): """Get active columns ordered by position. If project_id is None, returns global columns.""" try: # Force a fresh query by using db.session directly and avoiding cache from app import db + query = db.session.query(cls).filter_by(is_active=True) if project_id is None: # Return global columns (project_id is NULL) - use IS NULL for PostgreSQL @@ -65,13 +69,14 @@ def get_active_columns(cls, project_id=None): # Table might not exist yet during migration print(f"Warning: Could not load kanban columns: {e}") return [] - + @classmethod def get_all_columns(cls, project_id=None): """Get all columns (including inactive) ordered by position. If project_id is None, returns global columns.""" try: # Force a fresh query by using db.session directly and avoiding cache from app import db + query = db.session.query(cls) if project_id is None: # Return global columns (project_id is NULL) - use IS NULL for PostgreSQL @@ -84,7 +89,7 @@ def get_all_columns(cls, project_id=None): # Table might not exist yet during migration print(f"Warning: Could not load all kanban columns: {e}") return [] - + @classmethod def get_column_by_key(cls, key, project_id=None): """Get column by its key and project_id. If project_id is None, searches global columns.""" @@ -100,16 +105,16 @@ def get_column_by_key(cls, key, project_id=None): # Table might not exist yet print(f"Warning: Could not find kanban column by key: {e}") return None - + @classmethod def get_valid_status_keys(cls, project_id=None): """Get list of all valid status keys (for validation). If project_id is None, returns global column keys.""" columns = cls.get_active_columns(project_id=project_id) if not columns: # Fallback to default statuses if table doesn't exist - return ['todo', 'in_progress', 'review', 'done', 'cancelled'] + return ["todo", "in_progress", "review", "done", "cancelled"] return [col.key for col in columns] - + @classmethod def initialize_default_columns(cls, project_id=None): """Initialize default kanban columns if none exist for the given project (or globally if project_id is None)""" @@ -118,60 +123,60 @@ def initialize_default_columns(cls, project_id=None): query = query.filter(cls.project_id.is_(None)) else: query = query.filter_by(project_id=project_id) - + if query.count() > 0: return False # Columns already exist - + default_columns = [ { - 'key': 'todo', - 'label': 'To Do', - 'icon': 'fas fa-list-check', - 'color': 'secondary', - 'position': 0, - 'is_system': True, - 'is_complete_state': False, - 'project_id': project_id + "key": "todo", + "label": "To Do", + "icon": "fas fa-list-check", + "color": "secondary", + "position": 0, + "is_system": True, + "is_complete_state": False, + "project_id": project_id, }, { - 'key': 'in_progress', - 'label': 'In Progress', - 'icon': 'fas fa-spinner', - 'color': 'warning', - 'position': 1, - 'is_system': True, - 'is_complete_state': False, - 'project_id': project_id + "key": "in_progress", + "label": "In Progress", + "icon": "fas fa-spinner", + "color": "warning", + "position": 1, + "is_system": True, + "is_complete_state": False, + "project_id": project_id, }, { - 'key': 'review', - 'label': 'Review', - 'icon': 'fas fa-user-check', - 'color': 'info', - 'position': 2, - 'is_system': False, - 'is_complete_state': False, - 'project_id': project_id + "key": "review", + "label": "Review", + "icon": "fas fa-user-check", + "color": "info", + "position": 2, + "is_system": False, + "is_complete_state": False, + "project_id": project_id, }, { - 'key': 'done', - 'label': 'Done', - 'icon': 'fas fa-check-circle', - 'color': 'success', - 'position': 3, - 'is_system': True, - 'is_complete_state': True, - 'project_id': project_id - } + "key": "done", + "label": "Done", + "icon": "fas fa-check-circle", + "color": "success", + "position": 3, + "is_system": True, + "is_complete_state": True, + "project_id": project_id, + }, ] - + for col_data in default_columns: column = cls(**col_data) db.session.add(column) - + db.session.commit() return True - + @classmethod def reorder_columns(cls, column_ids, project_id=None): """ @@ -186,9 +191,8 @@ def reorder_columns(cls, column_ids, project_id=None): if (project_id is None and column.project_id is None) or (column.project_id == project_id): column.position = position column.updated_at = now_in_app_timezone() - + db.session.commit() # Expire all cached data to force fresh reads db.session.expire_all() return True - diff --git a/app/models/lead.py b/app/models/lead.py index 47ecddc0..aea6e3e5 100644 --- a/app/models/lead.py +++ b/app/models/lead.py @@ -3,166 +3,171 @@ from app import db from app.utils.timezone import now_in_app_timezone + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) + class Lead(db.Model): """Lead model for managing potential clients""" - - __tablename__ = 'leads' - + + __tablename__ = "leads" + id = db.Column(db.Integer, primary_key=True) - + # Lead information first_name = db.Column(db.String(100), nullable=False) last_name = db.Column(db.String(100), nullable=False) company_name = db.Column(db.String(200), nullable=True) email = db.Column(db.String(200), nullable=True, index=True) phone = db.Column(db.String(50), nullable=True) - + # Lead details title = db.Column(db.String(100), nullable=True) source = db.Column(db.String(100), nullable=True) # 'website', 'referral', 'social', 'ad', etc. - status = db.Column(db.String(50), nullable=False, default='new', index=True) # 'new', 'contacted', 'qualified', 'converted', 'lost' - + status = db.Column( + db.String(50), nullable=False, default="new", index=True + ) # 'new', 'contacted', 'qualified', 'converted', 'lost' + # Lead scoring score = db.Column(db.Integer, nullable=True, default=0) # Lead score (0-100) - + # Estimated value estimated_value = db.Column(db.Numeric(10, 2), nullable=True) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Conversion - converted_to_client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) - converted_to_deal_id = db.Column(db.Integer, db.ForeignKey('deals.id'), nullable=True, index=True) + converted_to_client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + converted_to_deal_id = db.Column(db.Integer, db.ForeignKey("deals.id"), nullable=True, index=True) converted_at = db.Column(db.DateTime, nullable=True) - converted_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - + converted_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + # Notes notes = db.Column(db.Text, nullable=True) tags = db.Column(db.String(500), nullable=True) # Comma-separated tags - + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) # Lead owner + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) # Lead owner created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Relationships - converted_to_client = db.relationship('Client', foreign_keys=[converted_to_client_id], backref='converted_from_leads') - converted_to_deal = db.relationship('Deal', foreign_keys=[converted_to_deal_id]) - creator = db.relationship('User', foreign_keys=[created_by], backref='created_leads') - owner = db.relationship('User', foreign_keys=[owner_id], backref='owned_leads') - converter = db.relationship('User', foreign_keys=[converted_by], backref='converted_leads') - activities = db.relationship('LeadActivity', backref='lead', lazy='dynamic', cascade='all, delete-orphan') - + converted_to_client = db.relationship( + "Client", foreign_keys=[converted_to_client_id], backref="converted_from_leads" + ) + converted_to_deal = db.relationship("Deal", foreign_keys=[converted_to_deal_id]) + creator = db.relationship("User", foreign_keys=[created_by], backref="created_leads") + owner = db.relationship("User", foreign_keys=[owner_id], backref="owned_leads") + converter = db.relationship("User", foreign_keys=[converted_by], backref="converted_leads") + activities = db.relationship("LeadActivity", backref="lead", lazy="dynamic", cascade="all, delete-orphan") + def __init__(self, first_name, last_name, created_by, **kwargs): self.first_name = first_name.strip() self.last_name = last_name.strip() self.created_by = created_by - + # Set optional fields - self.company_name = kwargs.get('company_name', '').strip() if kwargs.get('company_name') else None - self.email = kwargs.get('email', '').strip() if kwargs.get('email') else None - self.phone = kwargs.get('phone', '').strip() if kwargs.get('phone') else None - self.title = kwargs.get('title', '').strip() if kwargs.get('title') else None - self.source = kwargs.get('source', '').strip() if kwargs.get('source') else None - self.status = kwargs.get('status', 'new').strip() - self.score = kwargs.get('score', 0) - self.estimated_value = Decimal(str(kwargs.get('estimated_value'))) if kwargs.get('estimated_value') else None - self.currency_code = kwargs.get('currency_code', 'EUR') - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - self.tags = kwargs.get('tags', '').strip() if kwargs.get('tags') else None - self.owner_id = kwargs.get('owner_id', created_by) # Default to creator - + self.company_name = kwargs.get("company_name", "").strip() if kwargs.get("company_name") else None + self.email = kwargs.get("email", "").strip() if kwargs.get("email") else None + self.phone = kwargs.get("phone", "").strip() if kwargs.get("phone") else None + self.title = kwargs.get("title", "").strip() if kwargs.get("title") else None + self.source = kwargs.get("source", "").strip() if kwargs.get("source") else None + self.status = kwargs.get("status", "new").strip() + self.score = kwargs.get("score", 0) + self.estimated_value = Decimal(str(kwargs.get("estimated_value"))) if kwargs.get("estimated_value") else None + self.currency_code = kwargs.get("currency_code", "EUR") + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + self.tags = kwargs.get("tags", "").strip() if kwargs.get("tags") else None + self.owner_id = kwargs.get("owner_id", created_by) # Default to creator + def __repr__(self): - return f'' - + return f"" + @property def full_name(self): """Get full name of lead""" return f"{self.first_name} {self.last_name}".strip() - + @property def display_name(self): """Get display name with company if available""" if self.company_name: return f"{self.full_name} ({self.company_name})" return self.full_name - + @property def is_converted(self): """Check if lead has been converted""" return self.converted_to_client_id is not None or self.converted_to_deal_id is not None - + @property def is_lost(self): """Check if lead is lost""" - return self.status == 'lost' - + return self.status == "lost" + def convert_to_client(self, client_id, user_id): """Convert lead to client""" self.converted_to_client_id = client_id - self.status = 'converted' + self.status = "converted" self.converted_at = local_now() self.converted_by = user_id self.updated_at = local_now() - + def convert_to_deal(self, deal_id, user_id): """Convert lead to deal""" self.converted_to_deal_id = deal_id - self.status = 'converted' + self.status = "converted" self.converted_at = local_now() self.converted_by = user_id self.updated_at = local_now() - + def mark_lost(self): """Mark lead as lost""" - self.status = 'lost' + self.status = "lost" self.updated_at = local_now() - + def to_dict(self): """Convert lead to dictionary""" return { - 'id': self.id, - 'first_name': self.first_name, - 'last_name': self.last_name, - 'full_name': self.full_name, - 'display_name': self.display_name, - 'company_name': self.company_name, - 'email': self.email, - 'phone': self.phone, - 'title': self.title, - 'source': self.source, - 'status': self.status, - 'score': self.score, - 'estimated_value': float(self.estimated_value) if self.estimated_value else None, - 'currency_code': self.currency_code, - 'converted_to_client_id': self.converted_to_client_id, - 'converted_to_deal_id': self.converted_to_deal_id, - 'converted_at': self.converted_at.isoformat() if self.converted_at else None, - 'converted_by': self.converted_by, - 'notes': self.notes, - 'tags': self.tags.split(',') if self.tags else [], - 'created_by': self.created_by, - 'owner_id': self.owner_id, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'is_converted': self.is_converted, - 'is_lost': self.is_lost + "id": self.id, + "first_name": self.first_name, + "last_name": self.last_name, + "full_name": self.full_name, + "display_name": self.display_name, + "company_name": self.company_name, + "email": self.email, + "phone": self.phone, + "title": self.title, + "source": self.source, + "status": self.status, + "score": self.score, + "estimated_value": float(self.estimated_value) if self.estimated_value else None, + "currency_code": self.currency_code, + "converted_to_client_id": self.converted_to_client_id, + "converted_to_deal_id": self.converted_to_deal_id, + "converted_at": self.converted_at.isoformat() if self.converted_at else None, + "converted_by": self.converted_by, + "notes": self.notes, + "tags": self.tags.split(",") if self.tags else [], + "created_by": self.created_by, + "owner_id": self.owner_id, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "is_converted": self.is_converted, + "is_lost": self.is_lost, } - + @classmethod def get_active_leads(cls, user_id=None): """Get active (non-converted, non-lost) leads, optionally filtered by owner""" - query = cls.query.filter(~cls.status.in_(['converted', 'lost'])) + query = cls.query.filter(~cls.status.in_(["converted", "lost"])) if user_id: query = query.filter_by(owner_id=user_id) return query.order_by(cls.score.desc(), cls.created_at.desc()).all() - + @classmethod def get_leads_by_status(cls, status): """Get leads by status""" return cls.query.filter_by(status=status).order_by(cls.score.desc(), cls.created_at.desc()).all() - diff --git a/app/models/lead_activity.py b/app/models/lead_activity.py index bbf519a0..0ca0dea7 100644 --- a/app/models/lead_activity.py +++ b/app/models/lead_activity.py @@ -2,65 +2,68 @@ from app import db from app.utils.timezone import now_in_app_timezone + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) + class LeadActivity(db.Model): """Model for tracking activities on leads""" - - __tablename__ = 'lead_activities' - + + __tablename__ = "lead_activities" + id = db.Column(db.Integer, primary_key=True) - lead_id = db.Column(db.Integer, db.ForeignKey('leads.id'), nullable=False, index=True) - + lead_id = db.Column(db.Integer, db.ForeignKey("leads.id"), nullable=False, index=True) + # Activity details - type = db.Column(db.String(50), nullable=False) # 'call', 'email', 'meeting', 'note', 'status_change', 'score_change' + type = db.Column( + db.String(50), nullable=False + ) # 'call', 'email', 'meeting', 'note', 'status_change', 'score_change' subject = db.Column(db.String(500), nullable=True) description = db.Column(db.Text, nullable=True) - + # Activity date activity_date = db.Column(db.DateTime, nullable=False, default=local_now, index=True) due_date = db.Column(db.DateTime, nullable=True) # For scheduled activities - + # Status - status = db.Column(db.String(50), nullable=True, default='completed') # 'completed', 'pending', 'cancelled' - + status = db.Column(db.String(50), nullable=True, default="completed") # 'completed', 'pending', 'cancelled' + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=local_now, nullable=False) - + # Relationships # Note: 'lead' backref is created by Lead.activities relationship - creator = db.relationship('User', foreign_keys=[created_by], backref='created_lead_activities') - + creator = db.relationship("User", foreign_keys=[created_by], backref="created_lead_activities") + def __init__(self, lead_id, type, created_by, **kwargs): self.lead_id = lead_id self.type = type.strip() self.created_by = created_by - + # Set optional fields - self.subject = kwargs.get('subject', '').strip() if kwargs.get('subject') else None - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.activity_date = kwargs.get('activity_date') or local_now() - self.due_date = kwargs.get('due_date') - self.status = kwargs.get('status', 'completed').strip() if kwargs.get('status') else 'completed' - + self.subject = kwargs.get("subject", "").strip() if kwargs.get("subject") else None + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.activity_date = kwargs.get("activity_date") or local_now() + self.due_date = kwargs.get("due_date") + self.status = kwargs.get("status", "completed").strip() if kwargs.get("status") else "completed" + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert activity to dictionary""" return { - 'id': self.id, - 'lead_id': self.lead_id, - 'type': self.type, - 'subject': self.subject, - 'description': self.description, - 'activity_date': self.activity_date.isoformat() if self.activity_date else None, - 'due_date': self.due_date.isoformat() if self.due_date else None, - 'status': self.status, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() if self.created_at else None + "id": self.id, + "lead_id": self.lead_id, + "type": self.type, + "subject": self.subject, + "description": self.description, + "activity_date": self.activity_date.isoformat() if self.activity_date else None, + "due_date": self.due_date.isoformat() if self.due_date else None, + "status": self.status, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, } - diff --git a/app/models/mileage.py b/app/models/mileage.py index 8b1524c7..e84f937d 100644 --- a/app/models/mileage.py +++ b/app/models/mileage.py @@ -6,76 +6,79 @@ class Mileage(db.Model): """Mileage tracking for business travel""" - - __tablename__ = 'mileage' - + + __tablename__ = "mileage" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) - expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + expense_id = db.Column(db.Integer, db.ForeignKey("expenses.id"), nullable=True, index=True) + # Trip details trip_date = db.Column(db.Date, nullable=False, index=True) purpose = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - + # Location information start_location = db.Column(db.String(200), nullable=False) end_location = db.Column(db.String(200), nullable=False) start_odometer = db.Column(db.Numeric(10, 2), nullable=True) # Optional odometer readings end_odometer = db.Column(db.Numeric(10, 2), nullable=True) - + # Distance and calculation distance_km = db.Column(db.Numeric(10, 2), nullable=False) distance_miles = db.Column(db.Numeric(10, 2), nullable=True) # Computed or manual rate_per_km = db.Column(db.Numeric(10, 4), nullable=False) # Rate at time of entry rate_per_mile = db.Column(db.Numeric(10, 4), nullable=True) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Vehicle information vehicle_type = db.Column(db.String(50), nullable=True) # 'car', 'motorcycle', 'van', 'truck' vehicle_description = db.Column(db.String(200), nullable=True) # e.g., "BMW 3 Series" license_plate = db.Column(db.String(20), nullable=True) - + # Calculated amount calculated_amount = db.Column(db.Numeric(10, 2), nullable=False) - + # Round trip is_round_trip = db.Column(db.Boolean, default=False, nullable=False) - + # Status and approval - status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed' - approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + status = db.Column( + db.String(20), default="pending", nullable=False + ) # 'pending', 'approved', 'rejected', 'reimbursed' + approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) approved_at = db.Column(db.DateTime, nullable=True) rejection_reason = db.Column(db.Text, nullable=True) - + # Reimbursement reimbursed = db.Column(db.Boolean, default=False, nullable=False) reimbursed_at = db.Column(db.DateTime, nullable=True) - + # Notes notes = db.Column(db.Text, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('mileage_entries', lazy='dynamic')) - approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_mileage', lazy='dynamic')) - project = db.relationship('Project', backref=db.backref('mileage_entries', lazy='dynamic')) - client = db.relationship('Client', backref=db.backref('mileage_entries', lazy='dynamic')) - expense = db.relationship('Expense', backref=db.backref('mileage_entry', uselist=False)) - + user = db.relationship("User", foreign_keys=[user_id], backref=db.backref("mileage_entries", lazy="dynamic")) + approver = db.relationship( + "User", foreign_keys=[approved_by], backref=db.backref("approved_mileage", lazy="dynamic") + ) + project = db.relationship("Project", backref=db.backref("mileage_entries", lazy="dynamic")) + client = db.relationship("Client", backref=db.backref("mileage_entries", lazy="dynamic")) + expense = db.relationship("Expense", backref=db.backref("mileage_entry", uselist=False)) + # Indexes for common queries __table_args__ = ( - Index('ix_mileage_user_date', 'user_id', 'trip_date'), - Index('ix_mileage_status_date', 'status', 'trip_date'), + Index("ix_mileage_user_date", "user_id", "trip_date"), + Index("ix_mileage_status_date", "status", "trip_date"), ) - - def __init__(self, user_id, trip_date, purpose, start_location, end_location, - distance_km, rate_per_km, **kwargs): + + def __init__(self, user_id, trip_date, purpose, start_location, end_location, distance_km, rate_per_km, **kwargs): self.user_id = user_id self.trip_date = trip_date self.purpose = purpose.strip() @@ -83,167 +86,170 @@ def __init__(self, user_id, trip_date, purpose, start_location, end_location, self.end_location = end_location.strip() self.distance_km = Decimal(str(distance_km)) self.rate_per_km = Decimal(str(rate_per_km)) - + # Calculate amount self.calculated_amount = self.distance_km * self.rate_per_km - + # Optional fields - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.project_id = kwargs.get('project_id') - self.client_id = kwargs.get('client_id') - self.expense_id = kwargs.get('expense_id') - self.start_odometer = Decimal(str(kwargs.get('start_odometer'))) if kwargs.get('start_odometer') else None - self.end_odometer = Decimal(str(kwargs.get('end_odometer'))) if kwargs.get('end_odometer') else None - self.distance_miles = Decimal(str(kwargs.get('distance_miles'))) if kwargs.get('distance_miles') else self.distance_km * Decimal('0.621371') - self.rate_per_mile = Decimal(str(kwargs.get('rate_per_mile'))) if kwargs.get('rate_per_mile') else None - self.currency_code = kwargs.get('currency_code', 'EUR') - self.vehicle_type = kwargs.get('vehicle_type') - self.vehicle_description = kwargs.get('vehicle_description') - self.license_plate = kwargs.get('license_plate') - self.is_round_trip = kwargs.get('is_round_trip', False) - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - self.status = kwargs.get('status', 'pending') - + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.project_id = kwargs.get("project_id") + self.client_id = kwargs.get("client_id") + self.expense_id = kwargs.get("expense_id") + self.start_odometer = Decimal(str(kwargs.get("start_odometer"))) if kwargs.get("start_odometer") else None + self.end_odometer = Decimal(str(kwargs.get("end_odometer"))) if kwargs.get("end_odometer") else None + self.distance_miles = ( + Decimal(str(kwargs.get("distance_miles"))) + if kwargs.get("distance_miles") + else self.distance_km * Decimal("0.621371") + ) + self.rate_per_mile = Decimal(str(kwargs.get("rate_per_mile"))) if kwargs.get("rate_per_mile") else None + self.currency_code = kwargs.get("currency_code", "EUR") + self.vehicle_type = kwargs.get("vehicle_type") + self.vehicle_description = kwargs.get("vehicle_description") + self.license_plate = kwargs.get("license_plate") + self.is_round_trip = kwargs.get("is_round_trip", False) + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + self.status = kwargs.get("status", "pending") + def __repr__(self): - return f' {self.end_location} ({self.distance_km} km)>' - + return f" {self.end_location} ({self.distance_km} km)>" + @property def total_distance_km(self): """Get total distance including round trip if applicable""" multiplier = 2 if self.is_round_trip else 1 return float(self.distance_km) * multiplier - + @property def total_amount(self): """Get total amount including round trip if applicable""" multiplier = 2 if self.is_round_trip else 1 return float(self.calculated_amount) * multiplier - + def approve(self, approved_by_user_id, notes=None): """Approve the mileage entry""" - self.status = 'approved' + self.status = "approved" self.approved_by = approved_by_user_id self.approved_at = datetime.utcnow() if notes: - self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}' + self.notes = (self.notes or "") + f"\n\nApproval notes: {notes}" self.updated_at = datetime.utcnow() - + def reject(self, rejected_by_user_id, reason): """Reject the mileage entry""" - self.status = 'rejected' + self.status = "rejected" self.approved_by = rejected_by_user_id self.approved_at = datetime.utcnow() self.rejection_reason = reason self.updated_at = datetime.utcnow() - + def mark_as_reimbursed(self): """Mark this mileage entry as reimbursed""" self.reimbursed = True self.reimbursed_at = datetime.utcnow() - self.status = 'reimbursed' + self.status = "reimbursed" self.updated_at = datetime.utcnow() - + def create_expense(self): """Create an expense from this mileage entry""" from app.models.expense import Expense - + if self.expense_id: return None # Already has an expense - + expense = Expense( user_id=self.user_id, title=f"Mileage: {self.start_location} to {self.end_location}", - category='travel', + category="travel", amount=self.total_amount, expense_date=self.trip_date, description=f"{self.purpose}\nDistance: {self.total_distance_km} km @ {float(self.rate_per_km)} {self.currency_code}/km", project_id=self.project_id, client_id=self.client_id, currency_code=self.currency_code, - status=self.status + status=self.status, ) - + return expense - + def to_dict(self): """Convert mileage entry to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'project_id': self.project_id, - 'client_id': self.client_id, - 'expense_id': self.expense_id, - 'trip_date': self.trip_date.isoformat() if self.trip_date else None, - 'purpose': self.purpose, - 'description': self.description, - 'start_location': self.start_location, - 'end_location': self.end_location, - 'start_odometer': float(self.start_odometer) if self.start_odometer else None, - 'end_odometer': float(self.end_odometer) if self.end_odometer else None, - 'distance_km': float(self.distance_km), - 'distance_miles': float(self.distance_miles) if self.distance_miles else None, - 'rate_per_km': float(self.rate_per_km), - 'rate_per_mile': float(self.rate_per_mile) if self.rate_per_mile else None, - 'currency_code': self.currency_code, - 'vehicle_type': self.vehicle_type, - 'vehicle_description': self.vehicle_description, - 'license_plate': self.license_plate, - 'calculated_amount': float(self.calculated_amount), - 'is_round_trip': self.is_round_trip, - 'total_distance_km': self.total_distance_km, - 'total_amount': self.total_amount, - 'status': self.status, - 'approved_by': self.approved_by, - 'approved_at': self.approved_at.isoformat() if self.approved_at else None, - 'rejection_reason': self.rejection_reason, - 'reimbursed': self.reimbursed, - 'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'user': self.user.username if self.user else None, - 'project': self.project.name if self.project else None, - 'client': self.client.name if self.client else None, - 'approver': self.approver.username if self.approver else None + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "client_id": self.client_id, + "expense_id": self.expense_id, + "trip_date": self.trip_date.isoformat() if self.trip_date else None, + "purpose": self.purpose, + "description": self.description, + "start_location": self.start_location, + "end_location": self.end_location, + "start_odometer": float(self.start_odometer) if self.start_odometer else None, + "end_odometer": float(self.end_odometer) if self.end_odometer else None, + "distance_km": float(self.distance_km), + "distance_miles": float(self.distance_miles) if self.distance_miles else None, + "rate_per_km": float(self.rate_per_km), + "rate_per_mile": float(self.rate_per_mile) if self.rate_per_mile else None, + "currency_code": self.currency_code, + "vehicle_type": self.vehicle_type, + "vehicle_description": self.vehicle_description, + "license_plate": self.license_plate, + "calculated_amount": float(self.calculated_amount), + "is_round_trip": self.is_round_trip, + "total_distance_km": self.total_distance_km, + "total_amount": self.total_amount, + "status": self.status, + "approved_by": self.approved_by, + "approved_at": self.approved_at.isoformat() if self.approved_at else None, + "rejection_reason": self.rejection_reason, + "reimbursed": self.reimbursed, + "reimbursed_at": self.reimbursed_at.isoformat() if self.reimbursed_at else None, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "user": self.user.username if self.user else None, + "project": self.project.name if self.project else None, + "client": self.client.name if self.client else None, + "approver": self.approver.username if self.approver else None, } - + @classmethod def get_default_rates(cls): """Get default mileage rates for different vehicle types""" # These are example rates and should be configurable in settings return { - 'car': {'km': 0.30, 'mile': 0.48, 'currency': 'EUR'}, - 'motorcycle': {'km': 0.20, 'mile': 0.32, 'currency': 'EUR'}, - 'van': {'km': 0.35, 'mile': 0.56, 'currency': 'EUR'}, - 'truck': {'km': 0.40, 'mile': 0.64, 'currency': 'EUR'} + "car": {"km": 0.30, "mile": 0.48, "currency": "EUR"}, + "motorcycle": {"km": 0.20, "mile": 0.32, "currency": "EUR"}, + "van": {"km": 0.35, "mile": 0.56, "currency": "EUR"}, + "truck": {"km": 0.40, "mile": 0.64, "currency": "EUR"}, } - + @classmethod def get_pending_approvals(cls, user_id=None): """Get mileage entries pending approval""" - query = cls.query.filter_by(status='pending') - + query = cls.query.filter_by(status="pending") + if user_id: query = query.filter(cls.user_id == user_id) - + return query.order_by(cls.trip_date.desc()).all() - + @classmethod def get_total_distance(cls, user_id=None, start_date=None, end_date=None): """Calculate total distance traveled""" query = db.session.query(db.func.sum(cls.distance_km)) - + if user_id: query = query.filter(cls.user_id == user_id) - + if start_date: query = query.filter(cls.trip_date >= start_date) - + if end_date: query = query.filter(cls.trip_date <= end_date) - - query = query.filter(cls.status.in_(['approved', 'reimbursed'])) - - total = query.scalar() or Decimal('0') - return float(total) + query = query.filter(cls.status.in_(["approved", "reimbursed"])) + + total = query.scalar() or Decimal("0") + return float(total) diff --git a/app/models/payment_gateway.py b/app/models/payment_gateway.py index 6e8ec868..7bdf4996 100644 --- a/app/models/payment_gateway.py +++ b/app/models/payment_gateway.py @@ -1,4 +1,5 @@ """Payment gateway integration models""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,95 +7,94 @@ class PaymentGateway(db.Model): """Payment gateway configuration""" - - __tablename__ = 'payment_gateways' - + + __tablename__ = "payment_gateways" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), nullable=False, unique=True, index=True) # Name: 'stripe', 'paypal', 'square', etc. - + provider = db.Column(db.String(50), nullable=False) # Provider: 'stripe', 'paypal', 'square' - + # Configuration (encrypted JSON) # Contains: api_key, secret_key, webhook_secret, etc. config = db.Column(db.Text, nullable=False) # Encrypted - + # Status is_active = db.Column(db.Boolean, default=True, nullable=False, index=True) is_test_mode = db.Column(db.Boolean, default=False, nullable=False) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + def __repr__(self): - return f'' + return f"" class PaymentTransaction(db.Model): """Payment transaction from gateway""" - - __tablename__ = 'payment_transactions' - + + __tablename__ = "payment_transactions" + id = db.Column(db.Integer, primary_key=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) - gateway_id = db.Column(db.Integer, db.ForeignKey('payment_gateways.id'), nullable=False, index=True) - + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=False, index=True) + gateway_id = db.Column(db.Integer, db.ForeignKey("payment_gateways.id"), nullable=False, index=True) + # Transaction details transaction_id = db.Column(db.String(200), nullable=False, unique=True, index=True) # Gateway transaction ID (e.g., Stripe charge ID) - + amount = db.Column(db.Numeric(10, 2), nullable=False) - currency = db.Column(db.String(3), nullable=False, default='EUR') - + currency = db.Column(db.String(3), nullable=False, default="EUR") + # Gateway fees gateway_fee = db.Column(db.Numeric(10, 2), nullable=True) net_amount = db.Column(db.Numeric(10, 2), nullable=True) - + # Status status = db.Column(db.String(20), nullable=False, index=True) # Status: 'pending', 'processing', 'completed', 'failed', 'refunded', 'cancelled' - + # Payment method payment_method = db.Column(db.String(50), nullable=True) # e.g., 'card', 'bank_transfer', 'paypal' - + # Gateway response (JSON) gateway_response = db.Column(db.JSON, nullable=True) - + # Error information error_message = db.Column(db.Text, nullable=True) error_code = db.Column(db.String(50), nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) processed_at = db.Column(db.DateTime, nullable=True) - + # Relationships - invoice = db.relationship('Invoice', backref='payment_transactions') - gateway = db.relationship('PaymentGateway', backref='transactions') - + invoice = db.relationship("Invoice", backref="payment_transactions") + gateway = db.relationship("PaymentGateway", backref="transactions") + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert transaction to dictionary""" return { - 'id': self.id, - 'invoice_id': self.invoice_id, - 'gateway_id': self.gateway_id, - 'transaction_id': self.transaction_id, - 'amount': float(self.amount) if self.amount else None, - 'currency': self.currency, - 'gateway_fee': float(self.gateway_fee) if self.gateway_fee else None, - 'net_amount': float(self.net_amount) if self.net_amount else None, - 'status': self.status, - 'payment_method': self.payment_method, - 'error_message': self.error_message, - 'error_code': self.error_code, - 'created_at': self.created_at.isoformat(), - 'processed_at': self.processed_at.isoformat() if self.processed_at else None + "id": self.id, + "invoice_id": self.invoice_id, + "gateway_id": self.gateway_id, + "transaction_id": self.transaction_id, + "amount": float(self.amount) if self.amount else None, + "currency": self.currency, + "gateway_fee": float(self.gateway_fee) if self.gateway_fee else None, + "net_amount": float(self.net_amount) if self.net_amount else None, + "status": self.status, + "payment_method": self.payment_method, + "error_message": self.error_message, + "error_code": self.error_code, + "created_at": self.created_at.isoformat(), + "processed_at": self.processed_at.isoformat() if self.processed_at else None, } - diff --git a/app/models/payments.py b/app/models/payments.py index 4d599fa7..a73f38c5 100644 --- a/app/models/payments.py +++ b/app/models/payments.py @@ -6,73 +6,73 @@ class Payment(db.Model): """Partial/full payments recorded against invoices.""" - __tablename__ = 'payments' + __tablename__ = "payments" id = db.Column(db.Integer, primary_key=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=False, index=True) amount = db.Column(db.Numeric(10, 2), nullable=False) currency = db.Column(db.String(3), nullable=True) # If multi-currency per payment payment_date = db.Column(db.Date, nullable=False, default=datetime.utcnow) method = db.Column(db.String(50), nullable=True) # bank_transfer, cash, check, credit_card, paypal, stripe, etc. reference = db.Column(db.String(100), nullable=True) # Transaction ID, check number, etc. notes = db.Column(db.Text, nullable=True) - status = db.Column(db.String(20), default='completed', nullable=False) # completed, pending, failed, refunded - + status = db.Column(db.String(20), default="completed", nullable=False) # completed, pending, failed, refunded + # Additional tracking fields - received_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # User who recorded the payment + received_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) # User who recorded the payment gateway_transaction_id = db.Column(db.String(255), nullable=True) # For payment gateway transactions gateway_fee = db.Column(db.Numeric(10, 2), nullable=True) # Transaction fees net_amount = db.Column(db.Numeric(10, 2), nullable=True) # Amount after fees - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - receiver = db.relationship('User', backref='received_payments', foreign_keys=[received_by]) + receiver = db.relationship("User", backref="received_payments", foreign_keys=[received_by]) def __repr__(self): return f"" - + def calculate_net_amount(self): """Calculate net amount after fees""" if self.gateway_fee: self.net_amount = self.amount - self.gateway_fee else: self.net_amount = self.amount - + def to_dict(self): """Convert payment to dictionary for API responses""" return { - 'id': self.id, - 'invoice_id': self.invoice_id, - 'amount': float(self.amount), - 'currency': self.currency, - 'payment_date': self.payment_date.isoformat() if self.payment_date else None, - 'method': self.method, - 'reference': self.reference, - 'notes': self.notes, - 'status': self.status, - 'received_by': self.received_by, - 'gateway_transaction_id': self.gateway_transaction_id, - 'gateway_fee': float(self.gateway_fee) if self.gateway_fee else None, - 'net_amount': float(self.net_amount) if self.net_amount else float(self.amount), - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "invoice_id": self.invoice_id, + "amount": float(self.amount), + "currency": self.currency, + "payment_date": self.payment_date.isoformat() if self.payment_date else None, + "method": self.method, + "reference": self.reference, + "notes": self.notes, + "status": self.status, + "received_by": self.received_by, + "gateway_transaction_id": self.gateway_transaction_id, + "gateway_fee": float(self.gateway_fee) if self.gateway_fee else None, + "net_amount": float(self.net_amount) if self.net_amount else float(self.amount), + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } class CreditNote(db.Model): """Credit notes issued to offset invoices.""" - __tablename__ = 'credit_notes' + __tablename__ = "credit_notes" id = db.Column(db.Integer, primary_key=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=False, index=True) credit_number = db.Column(db.String(50), unique=True, nullable=False, index=True) amount = db.Column(db.Numeric(10, 2), nullable=False) reason = db.Column(db.Text, nullable=True) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -83,10 +83,10 @@ def __repr__(self): class InvoiceReminderSchedule(db.Model): """Schedules to send invoice reminders before/after due dates.""" - __tablename__ = 'invoice_reminder_schedules' + __tablename__ = "invoice_reminder_schedules" id = db.Column(db.Integer, primary_key=True) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True) + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=False, index=True) days_offset = db.Column(db.Integer, nullable=False) # negative for before due, positive after recipients = db.Column(db.Text, nullable=True) # comma-separated; default to client email if empty template_name = db.Column(db.String(100), nullable=True) @@ -97,5 +97,3 @@ class InvoiceReminderSchedule(db.Model): def __repr__(self): return f"" - - diff --git a/app/models/per_diem.py b/app/models/per_diem.py index aa20a69d..90d429d4 100644 --- a/app/models/per_diem.py +++ b/app/models/per_diem.py @@ -6,13 +6,13 @@ class PerDiemRate(db.Model): """Per diem rate configuration for different locations""" - - __tablename__ = 'per_diem_rates' - + + __tablename__ = "per_diem_rates" + id = db.Column(db.Integer, primary_key=True) country = db.Column(db.String(100), nullable=False, index=True) city = db.Column(db.String(100), nullable=True, index=True) - + # Rates full_day_rate = db.Column(db.Numeric(10, 2), nullable=False) half_day_rate = db.Column(db.Numeric(10, 2), nullable=False) @@ -20,340 +20,343 @@ class PerDiemRate(db.Model): lunch_rate = db.Column(db.Numeric(10, 2), nullable=True) dinner_rate = db.Column(db.Numeric(10, 2), nullable=True) incidental_rate = db.Column(db.Numeric(10, 2), nullable=True) # Tips, etc. - - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Validity period effective_from = db.Column(db.Date, nullable=False, index=True) effective_to = db.Column(db.Date, nullable=True, index=True) - + # Settings is_active = db.Column(db.Boolean, default=True, nullable=False) notes = db.Column(db.Text, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + __table_args__ = ( - Index('ix_per_diem_rates_country_city', 'country', 'city'), - Index('ix_per_diem_rates_effective', 'effective_from', 'effective_to'), + Index("ix_per_diem_rates_country_city", "country", "city"), + Index("ix_per_diem_rates_effective", "effective_from", "effective_to"), ) - + def __init__(self, country, full_day_rate, half_day_rate, effective_from, **kwargs): self.country = country.strip() - self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None + self.city = kwargs.get("city", "").strip() if kwargs.get("city") else None self.full_day_rate = Decimal(str(full_day_rate)) self.half_day_rate = Decimal(str(half_day_rate)) - self.breakfast_rate = Decimal(str(kwargs.get('breakfast_rate'))) if kwargs.get('breakfast_rate') else None - self.lunch_rate = Decimal(str(kwargs.get('lunch_rate'))) if kwargs.get('lunch_rate') else None - self.dinner_rate = Decimal(str(kwargs.get('dinner_rate'))) if kwargs.get('dinner_rate') else None - self.incidental_rate = Decimal(str(kwargs.get('incidental_rate'))) if kwargs.get('incidental_rate') else None - self.currency_code = kwargs.get('currency_code', 'EUR') + self.breakfast_rate = Decimal(str(kwargs.get("breakfast_rate"))) if kwargs.get("breakfast_rate") else None + self.lunch_rate = Decimal(str(kwargs.get("lunch_rate"))) if kwargs.get("lunch_rate") else None + self.dinner_rate = Decimal(str(kwargs.get("dinner_rate"))) if kwargs.get("dinner_rate") else None + self.incidental_rate = Decimal(str(kwargs.get("incidental_rate"))) if kwargs.get("incidental_rate") else None + self.currency_code = kwargs.get("currency_code", "EUR") self.effective_from = effective_from - self.effective_to = kwargs.get('effective_to') - self.is_active = kwargs.get('is_active', True) - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - + self.effective_to = kwargs.get("effective_to") + self.is_active = kwargs.get("is_active", True) + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + def __repr__(self): location = f"{self.city}, {self.country}" if self.city else self.country - return f'' - + return f"" + def to_dict(self): """Convert rate to dictionary for API responses""" return { - 'id': self.id, - 'country': self.country, - 'city': self.city, - 'full_day_rate': float(self.full_day_rate), - 'half_day_rate': float(self.half_day_rate), - 'breakfast_rate': float(self.breakfast_rate) if self.breakfast_rate else None, - 'lunch_rate': float(self.lunch_rate) if self.lunch_rate else None, - 'dinner_rate': float(self.dinner_rate) if self.dinner_rate else None, - 'incidental_rate': float(self.incidental_rate) if self.incidental_rate else None, - 'currency_code': self.currency_code, - 'effective_from': self.effective_from.isoformat() if self.effective_from else None, - 'effective_to': self.effective_to.isoformat() if self.effective_to else None, - 'is_active': self.is_active, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "country": self.country, + "city": self.city, + "full_day_rate": float(self.full_day_rate), + "half_day_rate": float(self.half_day_rate), + "breakfast_rate": float(self.breakfast_rate) if self.breakfast_rate else None, + "lunch_rate": float(self.lunch_rate) if self.lunch_rate else None, + "dinner_rate": float(self.dinner_rate) if self.dinner_rate else None, + "incidental_rate": float(self.incidental_rate) if self.incidental_rate else None, + "currency_code": self.currency_code, + "effective_from": self.effective_from.isoformat() if self.effective_from else None, + "effective_to": self.effective_to.isoformat() if self.effective_to else None, + "is_active": self.is_active, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_rate_for_location(cls, country, city=None, date=None): """Get applicable per diem rate for a location and date""" from datetime import date as dt_date - + if date is None: date = dt_date.today() - - query = cls.query.filter( - cls.country == country, - cls.is_active == True, - cls.effective_from <= date - ) - + + query = cls.query.filter(cls.country == country, cls.is_active == True, cls.effective_from <= date) + if city: # Try to find city-specific rate first - city_rate = query.filter(cls.city == city).filter( - db.or_(cls.effective_to.is_(None), cls.effective_to >= date) - ).first() - + city_rate = ( + query.filter(cls.city == city) + .filter(db.or_(cls.effective_to.is_(None), cls.effective_to >= date)) + .first() + ) + if city_rate: return city_rate - + # Fall back to country rate - country_rate = query.filter(cls.city.is_(None)).filter( - db.or_(cls.effective_to.is_(None), cls.effective_to >= date) - ).first() - + country_rate = ( + query.filter(cls.city.is_(None)) + .filter(db.or_(cls.effective_to.is_(None), cls.effective_to >= date)) + .first() + ) + return country_rate class PerDiem(db.Model): """Per diem claim for business travel""" - - __tablename__ = 'per_diems' - + + __tablename__ = "per_diems" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) - expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True) - per_diem_rate_id = db.Column(db.Integer, db.ForeignKey('per_diem_rates.id'), nullable=True, index=True) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + expense_id = db.Column(db.Integer, db.ForeignKey("expenses.id"), nullable=True, index=True) + per_diem_rate_id = db.Column(db.Integer, db.ForeignKey("per_diem_rates.id"), nullable=True, index=True) + # Trip details trip_purpose = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - + # Date range start_date = db.Column(db.Date, nullable=False, index=True) end_date = db.Column(db.Date, nullable=False, index=True) departure_time = db.Column(db.Time, nullable=True) return_time = db.Column(db.Time, nullable=True) - + # Location country = db.Column(db.String(100), nullable=False) city = db.Column(db.String(100), nullable=True) - + # Calculation details full_days = db.Column(db.Integer, default=0, nullable=False) half_days = db.Column(db.Integer, default=0, nullable=False) - + # Meal deductions (if meals were provided) breakfast_provided = db.Column(db.Integer, default=0, nullable=False) # Number of breakfasts lunch_provided = db.Column(db.Integer, default=0, nullable=False) dinner_provided = db.Column(db.Integer, default=0, nullable=False) - + # Rates used (stored at time of creation) full_day_rate = db.Column(db.Numeric(10, 2), nullable=False) half_day_rate = db.Column(db.Numeric(10, 2), nullable=False) breakfast_deduction = db.Column(db.Numeric(10, 2), nullable=True) lunch_deduction = db.Column(db.Numeric(10, 2), nullable=True) dinner_deduction = db.Column(db.Numeric(10, 2), nullable=True) - + # Calculated amount calculated_amount = db.Column(db.Numeric(10, 2), nullable=False) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Status and approval - status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed' - approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + status = db.Column( + db.String(20), default="pending", nullable=False + ) # 'pending', 'approved', 'rejected', 'reimbursed' + approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) approved_at = db.Column(db.DateTime, nullable=True) rejection_reason = db.Column(db.Text, nullable=True) - + # Reimbursement reimbursed = db.Column(db.Boolean, default=False, nullable=False) reimbursed_at = db.Column(db.DateTime, nullable=True) - + # Notes notes = db.Column(db.Text, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('per_diem_claims', lazy='dynamic')) - approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_per_diems', lazy='dynamic')) - project = db.relationship('Project', backref=db.backref('per_diem_claims', lazy='dynamic')) - client = db.relationship('Client', backref=db.backref('per_diem_claims', lazy='dynamic')) - expense = db.relationship('Expense', backref=db.backref('per_diem_claim', uselist=False)) - rate = db.relationship('PerDiemRate', backref=db.backref('per_diem_claims', lazy='dynamic')) - + user = db.relationship("User", foreign_keys=[user_id], backref=db.backref("per_diem_claims", lazy="dynamic")) + approver = db.relationship( + "User", foreign_keys=[approved_by], backref=db.backref("approved_per_diems", lazy="dynamic") + ) + project = db.relationship("Project", backref=db.backref("per_diem_claims", lazy="dynamic")) + client = db.relationship("Client", backref=db.backref("per_diem_claims", lazy="dynamic")) + expense = db.relationship("Expense", backref=db.backref("per_diem_claim", uselist=False)) + rate = db.relationship("PerDiemRate", backref=db.backref("per_diem_claims", lazy="dynamic")) + # Indexes for common queries __table_args__ = ( - Index('ix_per_diems_user_date', 'user_id', 'start_date'), - Index('ix_per_diems_status_date', 'status', 'start_date'), + Index("ix_per_diems_user_date", "user_id", "start_date"), + Index("ix_per_diems_status_date", "status", "start_date"), ) - - def __init__(self, user_id, trip_purpose, start_date, end_date, country, - full_day_rate, half_day_rate, **kwargs): + + def __init__(self, user_id, trip_purpose, start_date, end_date, country, full_day_rate, half_day_rate, **kwargs): self.user_id = user_id self.trip_purpose = trip_purpose.strip() self.start_date = start_date self.end_date = end_date self.country = country.strip() - self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None - + self.city = kwargs.get("city", "").strip() if kwargs.get("city") else None + # Store rates self.full_day_rate = Decimal(str(full_day_rate)) self.half_day_rate = Decimal(str(half_day_rate)) - + # Optional fields - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.project_id = kwargs.get('project_id') - self.client_id = kwargs.get('client_id') - self.expense_id = kwargs.get('expense_id') - self.per_diem_rate_id = kwargs.get('per_diem_rate_id') - self.departure_time = kwargs.get('departure_time') - self.return_time = kwargs.get('return_time') - self.full_days = kwargs.get('full_days', 0) - self.half_days = kwargs.get('half_days', 0) - self.breakfast_provided = kwargs.get('breakfast_provided', 0) - self.lunch_provided = kwargs.get('lunch_provided', 0) - self.dinner_provided = kwargs.get('dinner_provided', 0) - self.breakfast_deduction = Decimal(str(kwargs.get('breakfast_deduction', 0))) - self.lunch_deduction = Decimal(str(kwargs.get('lunch_deduction', 0))) - self.dinner_deduction = Decimal(str(kwargs.get('dinner_deduction', 0))) - self.currency_code = kwargs.get('currency_code', 'EUR') - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - self.status = kwargs.get('status', 'pending') - + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.project_id = kwargs.get("project_id") + self.client_id = kwargs.get("client_id") + self.expense_id = kwargs.get("expense_id") + self.per_diem_rate_id = kwargs.get("per_diem_rate_id") + self.departure_time = kwargs.get("departure_time") + self.return_time = kwargs.get("return_time") + self.full_days = kwargs.get("full_days", 0) + self.half_days = kwargs.get("half_days", 0) + self.breakfast_provided = kwargs.get("breakfast_provided", 0) + self.lunch_provided = kwargs.get("lunch_provided", 0) + self.dinner_provided = kwargs.get("dinner_provided", 0) + self.breakfast_deduction = Decimal(str(kwargs.get("breakfast_deduction", 0))) + self.lunch_deduction = Decimal(str(kwargs.get("lunch_deduction", 0))) + self.dinner_deduction = Decimal(str(kwargs.get("dinner_deduction", 0))) + self.currency_code = kwargs.get("currency_code", "EUR") + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + self.status = kwargs.get("status", "pending") + # Calculate amount self.calculated_amount = self._calculate_amount() - + def _calculate_amount(self): """Calculate the per diem amount based on days and deductions""" # Base amount amount = (self.full_day_rate * self.full_days) + (self.half_day_rate * self.half_days) - + # Deduct provided meals - amount -= (self.breakfast_deduction * self.breakfast_provided) - amount -= (self.lunch_deduction * self.lunch_provided) - amount -= (self.dinner_deduction * self.dinner_provided) - - return max(Decimal('0'), amount) # Ensure non-negative - + amount -= self.breakfast_deduction * self.breakfast_provided + amount -= self.lunch_deduction * self.lunch_provided + amount -= self.dinner_deduction * self.dinner_provided + + return max(Decimal("0"), amount) # Ensure non-negative + def recalculate_amount(self): """Recalculate the amount (useful when days or deductions change)""" self.calculated_amount = self._calculate_amount() return self.calculated_amount - + def __repr__(self): location = f"{self.city}, {self.country}" if self.city else self.country - return f'' - + return f"" + @property def total_days(self): """Get total number of days (full + half)""" return self.full_days + (self.half_days * 0.5) - + @property def trip_duration(self): """Get trip duration in days""" return (self.end_date - self.start_date).days + 1 - + def approve(self, approved_by_user_id, notes=None): """Approve the per diem claim""" - self.status = 'approved' + self.status = "approved" self.approved_by = approved_by_user_id self.approved_at = datetime.utcnow() if notes: - self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}' + self.notes = (self.notes or "") + f"\n\nApproval notes: {notes}" self.updated_at = datetime.utcnow() - + def reject(self, rejected_by_user_id, reason): """Reject the per diem claim""" - self.status = 'rejected' + self.status = "rejected" self.approved_by = rejected_by_user_id self.approved_at = datetime.utcnow() self.rejection_reason = reason self.updated_at = datetime.utcnow() - + def mark_as_reimbursed(self): """Mark this per diem claim as reimbursed""" self.reimbursed = True self.reimbursed_at = datetime.utcnow() - self.status = 'reimbursed' + self.status = "reimbursed" self.updated_at = datetime.utcnow() - + def create_expense(self): """Create an expense from this per diem claim""" from app.models.expense import Expense - + if self.expense_id: return None # Already has an expense - + location = f"{self.city}, {self.country}" if self.city else self.country - + expense = Expense( user_id=self.user_id, title=f"Per Diem: {location}", - category='meals', + category="meals", amount=self.calculated_amount, expense_date=self.start_date, description=f"{self.trip_purpose}\n{self.start_date} to {self.end_date} ({self.total_days} days)", project_id=self.project_id, client_id=self.client_id, currency_code=self.currency_code, - status=self.status + status=self.status, ) - + return expense - + def to_dict(self): """Convert per diem claim to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'project_id': self.project_id, - 'client_id': self.client_id, - 'expense_id': self.expense_id, - 'per_diem_rate_id': self.per_diem_rate_id, - 'trip_purpose': self.trip_purpose, - 'description': self.description, - 'start_date': self.start_date.isoformat() if self.start_date else None, - 'end_date': self.end_date.isoformat() if self.end_date else None, - 'departure_time': self.departure_time.isoformat() if self.departure_time else None, - 'return_time': self.return_time.isoformat() if self.return_time else None, - 'country': self.country, - 'city': self.city, - 'full_days': self.full_days, - 'half_days': self.half_days, - 'total_days': self.total_days, - 'trip_duration': self.trip_duration, - 'breakfast_provided': self.breakfast_provided, - 'lunch_provided': self.lunch_provided, - 'dinner_provided': self.dinner_provided, - 'full_day_rate': float(self.full_day_rate), - 'half_day_rate': float(self.half_day_rate), - 'breakfast_deduction': float(self.breakfast_deduction) if self.breakfast_deduction else None, - 'lunch_deduction': float(self.lunch_deduction) if self.lunch_deduction else None, - 'dinner_deduction': float(self.dinner_deduction) if self.dinner_deduction else None, - 'calculated_amount': float(self.calculated_amount), - 'currency_code': self.currency_code, - 'status': self.status, - 'approved_by': self.approved_by, - 'approved_at': self.approved_at.isoformat() if self.approved_at else None, - 'rejection_reason': self.rejection_reason, - 'reimbursed': self.reimbursed, - 'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'user': self.user.username if self.user else None, - 'project': self.project.name if self.project else None, - 'client': self.client.name if self.client else None, - 'approver': self.approver.username if self.approver else None + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "client_id": self.client_id, + "expense_id": self.expense_id, + "per_diem_rate_id": self.per_diem_rate_id, + "trip_purpose": self.trip_purpose, + "description": self.description, + "start_date": self.start_date.isoformat() if self.start_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "departure_time": self.departure_time.isoformat() if self.departure_time else None, + "return_time": self.return_time.isoformat() if self.return_time else None, + "country": self.country, + "city": self.city, + "full_days": self.full_days, + "half_days": self.half_days, + "total_days": self.total_days, + "trip_duration": self.trip_duration, + "breakfast_provided": self.breakfast_provided, + "lunch_provided": self.lunch_provided, + "dinner_provided": self.dinner_provided, + "full_day_rate": float(self.full_day_rate), + "half_day_rate": float(self.half_day_rate), + "breakfast_deduction": float(self.breakfast_deduction) if self.breakfast_deduction else None, + "lunch_deduction": float(self.lunch_deduction) if self.lunch_deduction else None, + "dinner_deduction": float(self.dinner_deduction) if self.dinner_deduction else None, + "calculated_amount": float(self.calculated_amount), + "currency_code": self.currency_code, + "status": self.status, + "approved_by": self.approved_by, + "approved_at": self.approved_at.isoformat() if self.approved_at else None, + "rejection_reason": self.rejection_reason, + "reimbursed": self.reimbursed, + "reimbursed_at": self.reimbursed_at.isoformat() if self.reimbursed_at else None, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "user": self.user.username if self.user else None, + "project": self.project.name if self.project else None, + "client": self.client.name if self.client else None, + "approver": self.approver.username if self.approver else None, } - + @classmethod def calculate_days_from_dates(cls, start_date, end_date, departure_time=None, return_time=None): """ Calculate full and half days based on departure and return times. - + Rules: - Departure before 12:00 = full day - Departure after 12:00 = half day @@ -362,12 +365,12 @@ def calculate_days_from_dates(cls, start_date, end_date, departure_time=None, re - Middle days = full days """ from datetime import time as dt_time - + if start_date > end_date: - return {'full_days': 0, 'half_days': 0} - + return {"full_days": 0, "half_days": 0} + trip_days = (end_date - start_date).days + 1 - + if trip_days == 1: # Single day trip if departure_time and return_time: @@ -375,44 +378,43 @@ def calculate_days_from_dates(cls, start_date, end_date, departure_time=None, re departure_datetime = datetime.combine(start_date, departure_time) return_datetime = datetime.combine(end_date, return_time) hours = (return_datetime - departure_datetime).total_seconds() / 3600 - + if hours >= 8: - return {'full_days': 1, 'half_days': 0} + return {"full_days": 1, "half_days": 0} else: - return {'full_days': 0, 'half_days': 1} + return {"full_days": 0, "half_days": 1} else: # Default to half day for single day - return {'full_days': 0, 'half_days': 1} - + return {"full_days": 0, "half_days": 1} + full_days = 0 half_days = 0 - + # First day noon = dt_time(12, 0) if departure_time and departure_time < noon: full_days += 1 else: half_days += 1 - + # Middle days (all full days) if trip_days > 2: - full_days += (trip_days - 2) - + full_days += trip_days - 2 + # Last day if return_time and return_time >= noon: full_days += 1 else: half_days += 1 - - return {'full_days': full_days, 'half_days': half_days} - + + return {"full_days": full_days, "half_days": half_days} + @classmethod def get_pending_approvals(cls, user_id=None): """Get per diem claims pending approval""" - query = cls.query.filter_by(status='pending') - + query = cls.query.filter_by(status="pending") + if user_id: query = query.filter(cls.user_id == user_id) - - return query.order_by(cls.start_date.desc()).all() + return query.order_by(cls.start_date.desc()).all() diff --git a/app/models/permission.py b/app/models/permission.py index 751cf221..8a51c057 100644 --- a/app/models/permission.py +++ b/app/models/permission.py @@ -1,108 +1,113 @@ """Permission model for granular access control""" + from datetime import datetime from app import db class Permission(db.Model): """Permission model - represents a single permission in the system""" - - __tablename__ = 'permissions' - + + __tablename__ = "permissions" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), unique=True, nullable=False, index=True) description = db.Column(db.String(255), nullable=True) - category = db.Column(db.String(50), nullable=False, index=True) # e.g., 'time_entries', 'projects', 'users', 'reports', 'system' + category = db.Column( + db.String(50), nullable=False, index=True + ) # e.g., 'time_entries', 'projects', 'users', 'reports', 'system' created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - - def __init__(self, name, description=None, category='general'): + + def __init__(self, name, description=None, category="general"): self.name = name self.description = description self.category = category - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert permission to dictionary""" return { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'category': self.category, - 'created_at': self.created_at.isoformat() if self.created_at else None + "id": self.id, + "name": self.name, + "description": self.description, + "category": self.category, + "created_at": self.created_at.isoformat() if self.created_at else None, } # Association table for many-to-many relationship between roles and permissions -role_permissions = db.Table('role_permissions', - db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), - db.Column('permission_id', db.Integer, db.ForeignKey('permissions.id', ondelete='CASCADE'), primary_key=True), - db.Column('created_at', db.DateTime, default=datetime.utcnow, nullable=False) +role_permissions = db.Table( + "role_permissions", + db.Column("role_id", db.Integer, db.ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True), + db.Column("permission_id", db.Integer, db.ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True), + db.Column("created_at", db.DateTime, default=datetime.utcnow, nullable=False), ) class Role(db.Model): """Role model - bundles permissions together""" - - __tablename__ = 'roles' - + + __tablename__ = "roles" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(50), unique=True, nullable=False, index=True) description = db.Column(db.String(255), nullable=True) is_system_role = db.Column(db.Boolean, default=False, nullable=False) # System roles cannot be deleted created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - permissions = db.relationship('Permission', secondary=role_permissions, lazy='joined', - backref=db.backref('roles', lazy='dynamic')) - + permissions = db.relationship( + "Permission", secondary=role_permissions, lazy="joined", backref=db.backref("roles", lazy="dynamic") + ) + def __init__(self, name, description=None, is_system_role=False): self.name = name self.description = description self.is_system_role = is_system_role - + def __repr__(self): - return f'' - + return f"" + def has_permission(self, permission_name): """Check if role has a specific permission""" return any(p.name == permission_name for p in self.permissions) - + def add_permission(self, permission): """Add a permission to this role""" if not self.has_permission(permission.name): self.permissions.append(permission) - + def remove_permission(self, permission): """Remove a permission from this role""" if self.has_permission(permission.name): self.permissions.remove(permission) - + def get_permission_names(self): """Get list of permission names for this role""" return [p.name for p in self.permissions] - + def to_dict(self, include_permissions=False): """Convert role to dictionary""" data = { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'is_system_role': self.is_system_role, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "name": self.name, + "description": self.description, + "is_system_role": self.is_system_role, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } if include_permissions: - data['permissions'] = [p.to_dict() for p in self.permissions] - data['permission_count'] = len(self.permissions) + data["permissions"] = [p.to_dict() for p in self.permissions] + data["permission_count"] = len(self.permissions) return data # Association table for many-to-many relationship between users and roles -user_roles = db.Table('user_roles', - db.Column('user_id', db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), primary_key=True), - db.Column('role_id', db.Integer, db.ForeignKey('roles.id', ondelete='CASCADE'), primary_key=True), - db.Column('assigned_at', db.DateTime, default=datetime.utcnow, nullable=False) +user_roles = db.Table( + "user_roles", + db.Column("user_id", db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), primary_key=True), + db.Column("role_id", db.Integer, db.ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True), + db.Column("assigned_at", db.DateTime, default=datetime.utcnow, nullable=False), ) - diff --git a/app/models/project.py b/app/models/project.py index 66b2363d..b76fce4e 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -2,22 +2,23 @@ from decimal import Decimal from app import db + class Project(db.Model): """Project model for client projects with billing information""" - - __tablename__ = 'projects' - + + __tablename__ = "projects" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False, index=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) - quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id'), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True) + quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id"), nullable=True, index=True) description = db.Column(db.Text, nullable=True) billable = db.Column(db.Boolean, default=True, nullable=False) hourly_rate = db.Column(db.Numeric(9, 2), nullable=True) billing_ref = db.Column(db.String(100), nullable=True) # Short project code for compact display (e.g., on Kanban cards) code = db.Column(db.String(20), nullable=True, unique=True, index=True) - status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'inactive', or 'archived' + status = db.Column(db.String(20), default="active", nullable=False) # 'active', 'inactive', or 'archived' # Estimates & budgets estimated_hours = db.Column(db.Float, nullable=True) budget_amount = db.Column(db.Numeric(10, 2), nullable=True) @@ -26,23 +27,37 @@ class Project(db.Model): updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) # Archiving metadata archived_at = db.Column(db.DateTime, nullable=True, index=True) - archived_by = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='SET NULL'), nullable=True) + archived_by = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) archived_reason = db.Column(db.Text, nullable=True) - + # Relationships - time_entries = db.relationship('TimeEntry', backref='project', lazy='dynamic', cascade='all, delete-orphan') - tasks = db.relationship('Task', backref='project', lazy='dynamic', cascade='all, delete-orphan') - costs = db.relationship('ProjectCost', backref='project', lazy='dynamic', cascade='all, delete-orphan') - extra_goods = db.relationship('ExtraGood', backref='project', lazy='dynamic', cascade='all, delete-orphan') + time_entries = db.relationship("TimeEntry", backref="project", lazy="dynamic", cascade="all, delete-orphan") + tasks = db.relationship("Task", backref="project", lazy="dynamic", cascade="all, delete-orphan") + costs = db.relationship("ProjectCost", backref="project", lazy="dynamic", cascade="all, delete-orphan") + extra_goods = db.relationship("ExtraGood", backref="project", lazy="dynamic", cascade="all, delete-orphan") # comments relationship is defined via backref in Comment model - - def __init__(self, name, client_id=None, description=None, billable=True, hourly_rate=None, billing_ref=None, client=None, budget_amount=None, budget_threshold_percent=80, code=None, created_by=None, status='active'): + + def __init__( + self, + name, + client_id=None, + description=None, + billable=True, + hourly_rate=None, + billing_ref=None, + client=None, + budget_amount=None, + budget_threshold_percent=80, + code=None, + created_by=None, + status="active", + ): """Create a Project. Backward-compatible initializer that accepts either client_id or client name. If client name is provided and client_id is not, the corresponding Client record will be found or created on the fly and client_id will be set. - + Note: created_by parameter is accepted for test compatibility but not used, as the Project model doesn't track creator information. """ @@ -78,30 +93,31 @@ def __init__(self, name, client_id=None, description=None, billable=True, hourly resolved_client_id = new_client.id self.client_id = resolved_client_id - + def __repr__(self): return f'' - + @property def client(self): """Get client name for backward compatibility""" return self.client_obj.name if self.client_obj else "Unknown Client" - + @property def is_active(self): """Check if project is active""" - return self.status == 'active' - + return self.status == "active" + @property def is_archived(self): """Check if project is archived""" - return self.status == 'archived' - + return self.status == "archived" + @property def archived_by_user(self): """Get the user who archived this project""" if self.archived_by: from .user import User + return User.query.get(self.archived_by) return None @@ -114,66 +130,67 @@ def code_display(self): if self.code: return self.code try: - base = (self.name or '').replace(' ', '') - return (base.upper()[:4]) if base else '' + base = (self.name or "").replace(" ", "") + return (base.upper()[:4]) if base else "" except Exception: - return '' - + return "" + @property def total_hours(self): """Calculate total hours spent on this project""" from .time_entry import TimeEntry - total_seconds = db.session.query( - db.func.sum(TimeEntry.duration_seconds) - ).filter( - TimeEntry.project_id == self.id, - TimeEntry.end_time.isnot(None) - ).scalar() or 0 + + total_seconds = ( + db.session.query(db.func.sum(TimeEntry.duration_seconds)) + .filter(TimeEntry.project_id == self.id, TimeEntry.end_time.isnot(None)) + .scalar() + or 0 + ) return round(total_seconds / 3600, 2) - + @property def total_billable_hours(self): """Calculate total billable hours spent on this project""" from .time_entry import TimeEntry - total_seconds = db.session.query( - db.func.sum(TimeEntry.duration_seconds) - ).filter( - TimeEntry.project_id == self.id, - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True - ).scalar() or 0 + + total_seconds = ( + db.session.query(db.func.sum(TimeEntry.duration_seconds)) + .filter(TimeEntry.project_id == self.id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True) + .scalar() + or 0 + ) return round(total_seconds / 3600, 2) - + @property def estimated_cost(self): """Calculate estimated cost based on billable hours and hourly rate""" if not self.billable or not self.hourly_rate: return 0.0 return float(self.total_billable_hours) * float(self.hourly_rate) - + @property def total_costs(self): """Calculate total project costs (expenses)""" from .project_cost import ProjectCost - total = db.session.query( - db.func.sum(ProjectCost.amount) - ).filter( - ProjectCost.project_id == self.id - ).scalar() or 0 + + total = ( + db.session.query(db.func.sum(ProjectCost.amount)).filter(ProjectCost.project_id == self.id).scalar() or 0 + ) return float(total) - + @property def total_billable_costs(self): """Calculate total billable project costs""" from .project_cost import ProjectCost - total = db.session.query( - db.func.sum(ProjectCost.amount) - ).filter( - ProjectCost.project_id == self.id, - ProjectCost.billable == True - ).scalar() or 0 + + total = ( + db.session.query(db.func.sum(ProjectCost.amount)) + .filter(ProjectCost.project_id == self.id, ProjectCost.billable == True) + .scalar() + or 0 + ) return float(total) - + @property def total_project_value(self): """Calculate total project value (billable hours + billable costs)""" @@ -192,6 +209,7 @@ def budget_consumed_amount(self): """ try: from .rate_override import RateOverride + hours = self.total_billable_hours # Use project-level override if present, else project rate rate = RateOverride.resolve_rate(self, user_id=None) @@ -210,132 +228,132 @@ def budget_threshold_exceeded(self): return self.budget_consumed_amount >= float(self.budget_amount) * threshold except Exception: return False - + def get_entries_by_user(self, user_id=None, start_date=None, end_date=None): """Get time entries for this project, optionally filtered by user and date range""" from .time_entry import TimeEntry + query = self.time_entries.filter(TimeEntry.end_time.isnot(None)) - + if user_id: query = query.filter(TimeEntry.user_id == user_id) - + if start_date: query = query.filter(TimeEntry.start_time >= start_date) - + if end_date: query = query.filter(TimeEntry.start_time <= end_date) - + return query.order_by(TimeEntry.start_time.desc()).all() - + def get_user_totals(self, start_date=None, end_date=None): """Get total hours per user for this project""" from .time_entry import TimeEntry from .user import User - - query = db.session.query( - User.id, - User.username, - User.full_name, - db.func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).join(TimeEntry).filter( - TimeEntry.project_id == self.id, - TimeEntry.end_time.isnot(None) + + query = ( + db.session.query( + User.id, User.username, User.full_name, db.func.sum(TimeEntry.duration_seconds).label("total_seconds") + ) + .join(TimeEntry) + .filter(TimeEntry.project_id == self.id, TimeEntry.end_time.isnot(None)) ) - + if start_date: query = query.filter(TimeEntry.start_time >= start_date) - + if end_date: query = query.filter(TimeEntry.start_time <= end_date) - + results = query.group_by(User.id, User.username, User.full_name).all() - + return [ { - 'username': (full_name.strip() if full_name and full_name.strip() else username), - 'total_hours': round(total_seconds / 3600, 2) + "username": (full_name.strip() if full_name and full_name.strip() else username), + "total_hours": round(total_seconds / 3600, 2), } for _id, username, full_name, total_seconds in results ] - + def archive(self, user_id=None, reason=None): """Archive the project with metadata - + Args: user_id: ID of the user archiving the project reason: Optional reason for archiving """ - self.status = 'archived' + self.status = "archived" self.archived_at = datetime.utcnow() self.archived_by = user_id self.archived_reason = reason self.updated_at = datetime.utcnow() db.session.commit() - + def unarchive(self): """Unarchive the project and clear archiving metadata""" - self.status = 'active' + self.status = "active" self.archived_at = None self.archived_by = None self.archived_reason = None self.updated_at = datetime.utcnow() db.session.commit() - + def deactivate(self): """Mark project as inactive""" - self.status = 'inactive' + self.status = "inactive" self.updated_at = datetime.utcnow() db.session.commit() - + def activate(self): """Activate the project""" - self.status = 'active' + self.status = "active" self.updated_at = datetime.utcnow() db.session.commit() - + def is_favorited_by(self, user): """Check if this project is favorited by a specific user""" from .user import User + if isinstance(user, int): user_id = user return self.favorited_by.filter_by(id=user_id).count() > 0 elif isinstance(user, User): return self.favorited_by.filter_by(id=user.id).count() > 0 return False - + def to_dict(self, user=None): """Convert project to dictionary for API responses""" data = { - 'id': self.id, - 'name': self.name, - 'code': self.code, - 'code_display': self.code_display, - 'client': self.client, - 'description': self.description, - 'billable': self.billable, - 'hourly_rate': float(self.hourly_rate) if self.hourly_rate else None, - 'billing_ref': self.billing_ref, - 'status': self.status, - 'estimated_hours': self.estimated_hours, - 'budget_amount': float(self.budget_amount) if self.budget_amount else None, - 'budget_threshold_percent': self.budget_threshold_percent, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'total_hours': self.total_hours, - 'total_billable_hours': self.total_billable_hours, - 'estimated_cost': float(self.estimated_cost) if self.estimated_cost else None, - 'budget_consumed_amount': self.budget_consumed_amount, - 'budget_threshold_exceeded': self.budget_threshold_exceeded, - 'total_costs': self.total_costs, - 'total_billable_costs': self.total_billable_costs, - 'total_project_value': self.total_project_value, + "id": self.id, + "name": self.name, + "code": self.code, + "code_display": self.code_display, + "client": self.client, + "description": self.description, + "billable": self.billable, + "hourly_rate": float(self.hourly_rate) if self.hourly_rate else None, + "billing_ref": self.billing_ref, + "status": self.status, + "estimated_hours": self.estimated_hours, + "budget_amount": float(self.budget_amount) if self.budget_amount else None, + "budget_threshold_percent": self.budget_threshold_percent, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "total_hours": self.total_hours, + "total_billable_hours": self.total_billable_hours, + "estimated_cost": float(self.estimated_cost) if self.estimated_cost else None, + "budget_consumed_amount": self.budget_consumed_amount, + "budget_threshold_exceeded": self.budget_threshold_exceeded, + "total_costs": self.total_costs, + "total_billable_costs": self.total_billable_costs, + "total_project_value": self.total_project_value, # Archiving metadata - 'is_archived': self.is_archived, - 'archived_at': self.archived_at.isoformat() if self.archived_at else None, - 'archived_by': self.archived_by, - 'archived_reason': self.archived_reason, + "is_archived": self.is_archived, + "archived_at": self.archived_at.isoformat() if self.archived_at else None, + "archived_by": self.archived_by, + "archived_reason": self.archived_reason, } # Include favorite status if user is provided if user: - data['is_favorite'] = self.is_favorited_by(user) + data["is_favorite"] = self.is_favorited_by(user) return data diff --git a/app/models/project_cost.py b/app/models/project_cost.py index fc96b072..6274b67c 100644 --- a/app/models/project_cost.py +++ b/app/models/project_cost.py @@ -2,39 +2,51 @@ from decimal import Decimal from app import db + class ProjectCost(db.Model): """Project cost model for tracking expenses beyond hourly work""" - - __tablename__ = 'project_costs' - + + __tablename__ = "project_costs" + id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Cost details description = db.Column(db.String(500), nullable=False) category = db.Column(db.String(50), nullable=False) # 'travel', 'materials', 'services', 'equipment', 'other' amount = db.Column(db.Numeric(10, 2), nullable=False) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Billing billable = db.Column(db.Boolean, default=True, nullable=False) invoiced = db.Column(db.Boolean, default=False, nullable=False) - invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=True, index=True) - + invoice_id = db.Column(db.Integer, db.ForeignKey("invoices.id"), nullable=True, index=True) + # Date and metadata cost_date = db.Column(db.Date, nullable=False, index=True) notes = db.Column(db.Text, nullable=True) receipt_path = db.Column(db.String(500), nullable=True) # Path to uploaded receipt - + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships # project and user relationships defined via backref - - def __init__(self, project_id, user_id, description, category, amount, cost_date, - billable=True, notes=None, currency_code='EUR', receipt_path=None): + + def __init__( + self, + project_id, + user_id, + description, + category, + amount, + cost_date, + billable=True, + notes=None, + currency_code="EUR", + receipt_path=None, + ): self.project_id = project_id self.user_id = user_id self.description = description.strip() if description else None @@ -45,120 +57,113 @@ def __init__(self, project_id, user_id, description, category, amount, cost_date self.notes = notes.strip() if notes else None self.currency_code = currency_code self.receipt_path = receipt_path - + def __repr__(self): - return f'' - + return f"" + @property def is_invoiced(self): """Check if this cost has been invoiced""" return self.invoiced and self.invoice_id is not None - + def mark_as_invoiced(self, invoice_id): """Mark this cost as invoiced""" self.invoiced = True self.invoice_id = invoice_id self.updated_at = datetime.utcnow() - + def unmark_as_invoiced(self): """Unmark this cost as invoiced (e.g., if invoice is deleted)""" self.invoiced = False self.invoice_id = None self.updated_at = datetime.utcnow() - + def to_dict(self): """Convert project cost to dictionary for API responses""" return { - 'id': self.id, - 'project_id': self.project_id, - 'user_id': self.user_id, - 'description': self.description, - 'category': self.category, - 'amount': float(self.amount), - 'currency_code': self.currency_code, - 'billable': self.billable, - 'invoiced': self.invoiced, - 'invoice_id': self.invoice_id, - 'cost_date': self.cost_date.isoformat() if self.cost_date else None, - 'notes': self.notes, - 'receipt_path': self.receipt_path, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'project': self.project.name if self.project else None, - 'user': self.user.username if self.user else None + "id": self.id, + "project_id": self.project_id, + "user_id": self.user_id, + "description": self.description, + "category": self.category, + "amount": float(self.amount), + "currency_code": self.currency_code, + "billable": self.billable, + "invoiced": self.invoiced, + "invoice_id": self.invoice_id, + "cost_date": self.cost_date.isoformat() if self.cost_date else None, + "notes": self.notes, + "receipt_path": self.receipt_path, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "project": self.project.name if self.project else None, + "user": self.user.username if self.user else None, } - + @classmethod def get_project_costs(cls, project_id, start_date=None, end_date=None, user_id=None, billable_only=False): """Get costs for a specific project with optional filters""" query = cls.query.filter_by(project_id=project_id) - + if start_date: query = query.filter(cls.cost_date >= start_date) - + if end_date: query = query.filter(cls.cost_date <= end_date) - + if user_id: query = query.filter(cls.user_id == user_id) - + if billable_only: query = query.filter(cls.billable == True) - + return query.order_by(cls.cost_date.desc()).all() - + @classmethod def get_total_costs(cls, project_id, start_date=None, end_date=None, user_id=None, billable_only=False): """Calculate total costs for a project with optional filters""" query = db.session.query(db.func.sum(cls.amount)).filter_by(project_id=project_id) - + if start_date: query = query.filter(cls.cost_date >= start_date) - + if end_date: query = query.filter(cls.cost_date <= end_date) - + if user_id: query = query.filter(cls.user_id == user_id) - + if billable_only: query = query.filter(cls.billable == True) - - total = query.scalar() or Decimal('0') + + total = query.scalar() or Decimal("0") return float(total) - + @classmethod def get_uninvoiced_costs(cls, project_id): """Get all billable costs that haven't been invoiced yet""" - return cls.query.filter_by( - project_id=project_id, - billable=True, - invoiced=False - ).order_by(cls.cost_date.desc()).all() - + return ( + cls.query.filter_by(project_id=project_id, billable=True, invoiced=False) + .order_by(cls.cost_date.desc()) + .all() + ) + @classmethod def get_costs_by_category(cls, project_id, start_date=None, end_date=None): """Get costs grouped by category""" query = db.session.query( - cls.category, - db.func.sum(cls.amount).label('total_amount'), - db.func.count(cls.id).label('count') + cls.category, db.func.sum(cls.amount).label("total_amount"), db.func.count(cls.id).label("count") ).filter_by(project_id=project_id) - + if start_date: query = query.filter(cls.cost_date >= start_date) - + if end_date: query = query.filter(cls.cost_date <= end_date) - + results = query.group_by(cls.category).all() - + return [ - { - 'category': category, - 'total_amount': float(total_amount), - 'count': count - } + {"category": category, "total_amount": float(total_amount), "count": count} for category, total_amount, count in results ] - diff --git a/app/models/project_stock_allocation.py b/app/models/project_stock_allocation.py index 1c627738..38462a5f 100644 --- a/app/models/project_stock_allocation.py +++ b/app/models/project_stock_allocation.py @@ -1,4 +1,5 @@ """ProjectStockAllocation model for tracking stock allocated to projects""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,61 +7,62 @@ class ProjectStockAllocation(db.Model): """ProjectStockAllocation model - tracks stock items allocated to projects""" - - __tablename__ = 'project_stock_allocations' - + + __tablename__ = "project_stock_allocations" + id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False, index=True) - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id', ondelete='CASCADE'), nullable=False, index=True) - warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True) + stock_item_id = db.Column( + db.Integer, db.ForeignKey("stock_items.id", ondelete="CASCADE"), nullable=False, index=True + ) + warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=False, index=True) quantity_allocated = db.Column(db.Numeric(10, 2), nullable=False) quantity_used = db.Column(db.Numeric(10, 2), nullable=False, default=0) - allocated_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + allocated_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) allocated_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) notes = db.Column(db.Text, nullable=True) - + # Relationships - project = db.relationship('Project', backref='stock_allocations') - stock_item = db.relationship('StockItem', backref='project_allocations') - warehouse = db.relationship('Warehouse', backref='project_allocations') - allocated_by_user = db.relationship('User', foreign_keys=[allocated_by]) - + project = db.relationship("Project", backref="stock_allocations") + stock_item = db.relationship("StockItem", backref="project_allocations") + warehouse = db.relationship("Warehouse", backref="project_allocations") + allocated_by_user = db.relationship("User", foreign_keys=[allocated_by]) + def __init__(self, project_id, stock_item_id, warehouse_id, quantity_allocated, allocated_by, notes=None): self.project_id = project_id self.stock_item_id = stock_item_id self.warehouse_id = warehouse_id self.quantity_allocated = Decimal(str(quantity_allocated)) self.allocated_by = allocated_by - self.quantity_used = Decimal('0') + self.quantity_used = Decimal("0") self.notes = notes.strip() if notes else None - + def __repr__(self): - return f'' - + return f"" + @property def quantity_remaining(self): """Calculate remaining allocated quantity""" return self.quantity_allocated - self.quantity_used - + def record_usage(self, quantity): """Record usage of allocated stock""" qty = Decimal(str(quantity)) if qty > self.quantity_remaining: raise ValueError(f"Cannot use more than allocated. Remaining: {self.quantity_remaining}, Requested: {qty}") self.quantity_used += qty - + def to_dict(self): """Convert project stock allocation to dictionary""" return { - 'id': self.id, - 'project_id': self.project_id, - 'stock_item_id': self.stock_item_id, - 'warehouse_id': self.warehouse_id, - 'quantity_allocated': float(self.quantity_allocated), - 'quantity_used': float(self.quantity_used), - 'quantity_remaining': float(self.quantity_remaining), - 'allocated_by': self.allocated_by, - 'allocated_at': self.allocated_at.isoformat() if self.allocated_at else None, - 'notes': self.notes + "id": self.id, + "project_id": self.project_id, + "stock_item_id": self.stock_item_id, + "warehouse_id": self.warehouse_id, + "quantity_allocated": float(self.quantity_allocated), + "quantity_used": float(self.quantity_used), + "quantity_remaining": float(self.quantity_remaining), + "allocated_by": self.allocated_by, + "allocated_at": self.allocated_at.isoformat() if self.allocated_at else None, + "notes": self.notes, } - diff --git a/app/models/project_template.py b/app/models/project_template.py index 6482a737..b9663870 100644 --- a/app/models/project_template.py +++ b/app/models/project_template.py @@ -1,4 +1,5 @@ """Project template model for reusable project configurations""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,59 +7,58 @@ class ProjectTemplate(db.Model): """Template for creating projects with pre-configured settings""" - - __tablename__ = 'project_templates' - + + __tablename__ = "project_templates" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False, index=True) description = db.Column(db.Text, nullable=True) - + # Template configuration (JSON) - # Contains: client_id (optional), description, billable, hourly_rate, + # Contains: client_id (optional), description, billable, hourly_rate, # billing_ref, code, estimated_hours, budget_amount, budget_threshold_percent config = db.Column(db.JSON, nullable=False, default=dict) - + # Template tasks (JSON array of task configurations) # Each task: {name, description, priority, status, estimated_hours} tasks = db.Column(db.JSON, nullable=True, default=list) - + # Template categories/tags category = db.Column(db.String(100), nullable=True, index=True) tags = db.Column(db.JSON, nullable=True, default=list) - + # Visibility is_public = db.Column(db.Boolean, default=False, nullable=False, index=True) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Usage statistics usage_count = db.Column(db.Integer, default=0, nullable=False) last_used_at = db.Column(db.DateTime, nullable=True) - + # Metadata created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - creator = db.relationship('User', backref='project_templates') - + creator = db.relationship("User", backref="project_templates") + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert template to dictionary""" return { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'config': self.config or {}, - 'tasks': self.tasks or [], - 'category': self.category, - 'tags': self.tags or [], - 'is_public': self.is_public, - 'created_by': self.created_by, - 'usage_count': self.usage_count, - 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, - 'created_at': self.created_at.isoformat(), - 'updated_at': self.updated_at.isoformat() + "id": self.id, + "name": self.name, + "description": self.description, + "config": self.config or {}, + "tasks": self.tasks or [], + "category": self.category, + "tags": self.tags or [], + "is_public": self.is_public, + "created_by": self.created_by, + "usage_count": self.usage_count, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + "created_at": self.created_at.isoformat(), + "updated_at": self.updated_at.isoformat(), } - diff --git a/app/models/purchase_order.py b/app/models/purchase_order.py index a6da7ee5..46a1ed64 100644 --- a/app/models/purchase_order.py +++ b/app/models/purchase_order.py @@ -1,4 +1,5 @@ """Purchase Order models for inventory management""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,36 +7,47 @@ class PurchaseOrder(db.Model): """PurchaseOrder model - represents a purchase order to a supplier""" - - __tablename__ = 'purchase_orders' - + + __tablename__ = "purchase_orders" + id = db.Column(db.Integer, primary_key=True) po_number = db.Column(db.String(50), unique=True, nullable=False, index=True) - supplier_id = db.Column(db.Integer, db.ForeignKey('suppliers.id'), nullable=False, index=True) - status = db.Column(db.String(20), default='draft', nullable=False, index=True) # draft, sent, confirmed, received, cancelled + supplier_id = db.Column(db.Integer, db.ForeignKey("suppliers.id"), nullable=False, index=True) + status = db.Column( + db.String(20), default="draft", nullable=False, index=True + ) # draft, sent, confirmed, received, cancelled order_date = db.Column(db.Date, nullable=False, index=True) expected_delivery_date = db.Column(db.Date, nullable=True) received_date = db.Column(db.Date, nullable=True) - + # Financial subtotal = db.Column(db.Numeric(10, 2), nullable=False, default=0) tax_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) shipping_cost = db.Column(db.Numeric(10, 2), nullable=False, default=0) total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Metadata notes = db.Column(db.Text, nullable=True) internal_notes = db.Column(db.Text, nullable=True) # Not visible to supplier created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Relationships - items = db.relationship('PurchaseOrderItem', backref='purchase_order', lazy='dynamic', cascade='all, delete-orphan') - - def __init__(self, po_number, supplier_id, order_date, created_by, expected_delivery_date=None, - notes=None, internal_notes=None, currency_code='EUR'): + items = db.relationship("PurchaseOrderItem", backref="purchase_order", lazy="dynamic", cascade="all, delete-orphan") + + def __init__( + self, + po_number, + supplier_id, + order_date, + created_by, + expected_delivery_date=None, + notes=None, + internal_notes=None, + currency_code="EUR", + ): self.po_number = po_number.strip().upper() self.supplier_id = supplier_id self.order_date = order_date @@ -44,99 +56,101 @@ def __init__(self, po_number, supplier_id, order_date, created_by, expected_deli self.notes = notes.strip() if notes else None self.internal_notes = internal_notes.strip() if internal_notes else None self.currency_code = currency_code.upper() - self.status = 'draft' - self.subtotal = Decimal('0') - self.tax_amount = Decimal('0') - self.shipping_cost = Decimal('0') - self.total_amount = Decimal('0') - + self.status = "draft" + self.subtotal = Decimal("0") + self.tax_amount = Decimal("0") + self.shipping_cost = Decimal("0") + self.total_amount = Decimal("0") + def __repr__(self): - return f'' - + return f"" + def calculate_totals(self): """Calculate subtotal, tax, and total from items""" self.subtotal = sum(item.line_total for item in self.items) # Tax calculation can be added later if needed self.total_amount = self.subtotal + self.tax_amount + self.shipping_cost self.updated_at = datetime.utcnow() - + def mark_as_sent(self): """Mark purchase order as sent to supplier""" - if self.status == 'draft': - self.status = 'sent' + if self.status == "draft": + self.status = "sent" self.updated_at = datetime.utcnow() - + def mark_as_received(self, received_date=None): """Mark purchase order as received""" # Allow receiving from draft, sent, or confirmed status - if self.status not in ['received', 'cancelled']: - self.status = 'received' + if self.status not in ["received", "cancelled"]: + self.status = "received" self.received_date = received_date or datetime.utcnow().date() self.updated_at = datetime.utcnow() - + # Create stock movements for received items for item in self.items: if item.stock_item_id and item.quantity_received and item.quantity_received > 0: from .stock_movement import StockMovement + # Use warehouse from item, or get first active warehouse warehouse_id = item.warehouse_id if not warehouse_id: from .warehouse import Warehouse + first_warehouse = Warehouse.query.filter_by(is_active=True).first() warehouse_id = first_warehouse.id if first_warehouse else None - + if warehouse_id: StockMovement.record_movement( - movement_type='purchase', + movement_type="purchase", stock_item_id=item.stock_item_id, warehouse_id=warehouse_id, quantity=item.quantity_received, moved_by=self.created_by, - reason=f'Purchase Order {self.po_number}', - reference_type='purchase_order', + reason=f"Purchase Order {self.po_number}", + reference_type="purchase_order", reference_id=self.id, unit_cost=item.unit_cost, - update_stock=True + update_stock=True, ) - + def cancel(self): """Cancel purchase order""" - if self.status not in ['received', 'cancelled']: - self.status = 'cancelled' + if self.status not in ["received", "cancelled"]: + self.status = "cancelled" self.updated_at = datetime.utcnow() - + def to_dict(self): """Convert purchase order to dictionary""" return { - 'id': self.id, - 'po_number': self.po_number, - 'supplier_id': self.supplier_id, - 'status': self.status, - 'order_date': self.order_date.isoformat() if self.order_date else None, - 'expected_delivery_date': self.expected_delivery_date.isoformat() if self.expected_delivery_date else None, - 'received_date': self.received_date.isoformat() if self.received_date else None, - 'subtotal': float(self.subtotal), - 'tax_amount': float(self.tax_amount), - 'shipping_cost': float(self.shipping_cost), - 'total_amount': float(self.total_amount), - 'currency_code': self.currency_code, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'created_by': self.created_by + "id": self.id, + "po_number": self.po_number, + "supplier_id": self.supplier_id, + "status": self.status, + "order_date": self.order_date.isoformat() if self.order_date else None, + "expected_delivery_date": self.expected_delivery_date.isoformat() if self.expected_delivery_date else None, + "received_date": self.received_date.isoformat() if self.received_date else None, + "subtotal": float(self.subtotal), + "tax_amount": float(self.tax_amount), + "shipping_cost": float(self.shipping_cost), + "total_amount": float(self.total_amount), + "currency_code": self.currency_code, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "created_by": self.created_by, } class PurchaseOrderItem(db.Model): """PurchaseOrderItem model - items in a purchase order""" - - __tablename__ = 'purchase_order_items' - + + __tablename__ = "purchase_order_items" + id = db.Column(db.Integer, primary_key=True) - purchase_order_id = db.Column(db.Integer, db.ForeignKey('purchase_orders.id'), nullable=False, index=True) - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True) - supplier_stock_item_id = db.Column(db.Integer, db.ForeignKey('supplier_stock_items.id'), nullable=True, index=True) - + purchase_order_id = db.Column(db.Integer, db.ForeignKey("purchase_orders.id"), nullable=False, index=True) + stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True) + supplier_stock_item_id = db.Column(db.Integer, db.ForeignKey("supplier_stock_items.id"), nullable=True, index=True) + # Item details description = db.Column(db.String(500), nullable=False) supplier_sku = db.Column(db.String(100), nullable=True) @@ -144,55 +158,64 @@ class PurchaseOrderItem(db.Model): quantity_received = db.Column(db.Numeric(10, 2), nullable=False, default=0) unit_cost = db.Column(db.Numeric(10, 2), nullable=False) line_total = db.Column(db.Numeric(10, 2), nullable=False) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Warehouse destination - warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=True, index=True) - + warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=True, index=True) + # Notes notes = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - - def __init__(self, purchase_order_id, description, quantity_ordered, unit_cost, - stock_item_id=None, supplier_stock_item_id=None, supplier_sku=None, - warehouse_id=None, notes=None, currency_code='EUR'): + + def __init__( + self, + purchase_order_id, + description, + quantity_ordered, + unit_cost, + stock_item_id=None, + supplier_stock_item_id=None, + supplier_sku=None, + warehouse_id=None, + notes=None, + currency_code="EUR", + ): self.purchase_order_id = purchase_order_id self.stock_item_id = stock_item_id self.supplier_stock_item_id = supplier_stock_item_id self.description = description.strip() self.supplier_sku = supplier_sku.strip() if supplier_sku else None self.quantity_ordered = Decimal(str(quantity_ordered)) - self.quantity_received = Decimal('0') + self.quantity_received = Decimal("0") self.unit_cost = Decimal(str(unit_cost)) self.line_total = self.quantity_ordered * self.unit_cost self.warehouse_id = warehouse_id self.notes = notes.strip() if notes else None self.currency_code = currency_code.upper() - + def __repr__(self): - return f'' - + return f"" + def update_line_total(self): """Recalculate line total""" self.line_total = self.quantity_ordered * self.unit_cost self.updated_at = datetime.utcnow() - + def to_dict(self): """Convert purchase order item to dictionary""" return { - 'id': self.id, - 'purchase_order_id': self.purchase_order_id, - 'stock_item_id': self.stock_item_id, - 'supplier_stock_item_id': self.supplier_stock_item_id, - 'description': self.description, - 'supplier_sku': self.supplier_sku, - 'quantity_ordered': float(self.quantity_ordered), - 'quantity_received': float(self.quantity_received), - 'unit_cost': float(self.unit_cost), - 'line_total': float(self.line_total), - 'currency_code': self.currency_code, - 'warehouse_id': self.warehouse_id, - 'notes': self.notes + "id": self.id, + "purchase_order_id": self.purchase_order_id, + "stock_item_id": self.stock_item_id, + "supplier_stock_item_id": self.supplier_stock_item_id, + "description": self.description, + "supplier_sku": self.supplier_sku, + "quantity_ordered": float(self.quantity_ordered), + "quantity_received": float(self.quantity_received), + "unit_cost": float(self.unit_cost), + "line_total": float(self.line_total), + "currency_code": self.currency_code, + "warehouse_id": self.warehouse_id, + "notes": self.notes, } - diff --git a/app/models/quote.py b/app/models/quote.py index 9e59802d..6e2be374 100644 --- a/app/models/quote.py +++ b/app/models/quote.py @@ -4,208 +4,222 @@ from app import db from app.utils.timezone import now_in_app_timezone + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) + class Quote(db.Model): """Quote model for managing client quotes that can be accepted as projects""" - - __tablename__ = 'quotes' - + + __tablename__ = "quotes" + id = db.Column(db.Integer, primary_key=True) quote_number = db.Column(db.String(50), unique=True, nullable=False, index=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) - + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True) + # Quote details title = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - status = db.Column(db.String(20), default='draft', nullable=False) # 'draft', 'sent', 'accepted', 'rejected', 'expired' - + status = db.Column( + db.String(20), default="draft", nullable=False + ) # 'draft', 'sent', 'accepted', 'rejected', 'expired' + # Financial details (calculated from items) subtotal = db.Column(db.Numeric(10, 2), nullable=False, default=0) tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0) # Tax rate percentage tax_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') - + currency_code = db.Column(db.String(3), nullable=False, default="EUR") + # Discount fields discount_type = db.Column(db.String(20), nullable=True) # 'percentage' or 'fixed' discount_amount = db.Column(db.Numeric(10, 2), nullable=True, default=0) # Discount value discount_reason = db.Column(db.String(500), nullable=True) # Reason for discount coupon_code = db.Column(db.String(50), nullable=True, index=True) # Optional coupon code - + # Validity and dates valid_until = db.Column(db.Date, nullable=True) # Quote expiration date sent_at = db.Column(db.DateTime, nullable=True) # When quote was sent to client accepted_at = db.Column(db.DateTime, nullable=True) # When quote was accepted rejected_at = db.Column(db.DateTime, nullable=True) # When quote was rejected - + # Approval Workflow fields - approval_status = db.Column(db.String(20), default='not_required', nullable=False) # 'not_required', 'pending', 'approved', 'rejected' - approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + approval_status = db.Column( + db.String(20), default="not_required", nullable=False + ) # 'not_required', 'pending', 'approved', 'rejected' + approved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) approved_at = db.Column(db.DateTime, nullable=True) rejection_reason = db.Column(db.Text, nullable=True) - rejected_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) - + rejected_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) + # Client portal visibility - visible_to_client = db.Column(db.Boolean, default=False, nullable=False) # Whether quote is visible in client portal - + visible_to_client = db.Column( + db.Boolean, default=False, nullable=False + ) # Whether quote is visible in client portal + # PDF template - template_id = db.Column(db.Integer, db.ForeignKey('quote_pdf_templates.id'), nullable=True, index=True) - + template_id = db.Column(db.Integer, db.ForeignKey("quote_pdf_templates.id"), nullable=True, index=True) + # Relationships - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) # Created project when accepted - + project_id = db.Column( + db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True + ) # Created project when accepted + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) - accepted_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + accepted_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Notes notes = db.Column(db.Text, nullable=True) # Internal notes terms = db.Column(db.Text, nullable=True) # Terms and conditions - + # Payment terms - payment_terms = db.Column(db.String(100), nullable=True) # e.g., "Net 30", "Net 60", "Due on Receipt", "2/10 Net 30" - + payment_terms = db.Column( + db.String(100), nullable=True + ) # e.g., "Net 30", "Net 60", "Due on Receipt", "2/10 Net 30" + # Relationships - client = db.relationship('Client', backref='quotes') - project = db.relationship('Project', primaryjoin='Quote.project_id == Project.id', foreign_keys='[Quote.project_id]', uselist=False) - creator = db.relationship('User', foreign_keys=[created_by], backref='created_quotes') - accepter = db.relationship('User', foreign_keys=[accepted_by], backref='accepted_quotes') - approver = db.relationship('User', foreign_keys=[approved_by], backref='approved_quotes') - rejecter = db.relationship('User', foreign_keys=[rejected_by], backref='rejected_quotes') - items = db.relationship('QuoteItem', backref='quote', lazy='dynamic', cascade='all, delete-orphan') - template = db.relationship('QuotePDFTemplate', backref='quotes', lazy='joined') - + client = db.relationship("Client", backref="quotes") + project = db.relationship( + "Project", primaryjoin="Quote.project_id == Project.id", foreign_keys="[Quote.project_id]", uselist=False + ) + creator = db.relationship("User", foreign_keys=[created_by], backref="created_quotes") + accepter = db.relationship("User", foreign_keys=[accepted_by], backref="accepted_quotes") + approver = db.relationship("User", foreign_keys=[approved_by], backref="approved_quotes") + rejecter = db.relationship("User", foreign_keys=[rejected_by], backref="rejected_quotes") + items = db.relationship("QuoteItem", backref="quote", lazy="dynamic", cascade="all, delete-orphan") + template = db.relationship("QuotePDFTemplate", backref="quotes", lazy="joined") + def __init__(self, quote_number, client_id, title, created_by, **kwargs): self.quote_number = quote_number self.client_id = client_id self.title = title.strip() self.created_by = created_by - + # Set optional fields - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.status = kwargs.get('status', 'draft') - self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0))) - self.currency_code = kwargs.get('currency_code', 'EUR') - self.valid_until = kwargs.get('valid_until') - self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None - self.terms = kwargs.get('terms', '').strip() if kwargs.get('terms') else None - self.payment_terms = kwargs.get('payment_terms', '').strip() if kwargs.get('payment_terms') else None - self.visible_to_client = kwargs.get('visible_to_client', False) - self.template_id = kwargs.get('template_id') - + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.status = kwargs.get("status", "draft") + self.tax_rate = Decimal(str(kwargs.get("tax_rate", 0))) + self.currency_code = kwargs.get("currency_code", "EUR") + self.valid_until = kwargs.get("valid_until") + self.notes = kwargs.get("notes", "").strip() if kwargs.get("notes") else None + self.terms = kwargs.get("terms", "").strip() if kwargs.get("terms") else None + self.payment_terms = kwargs.get("payment_terms", "").strip() if kwargs.get("payment_terms") else None + self.visible_to_client = kwargs.get("visible_to_client", False) + self.template_id = kwargs.get("template_id") + # Discount fields - self.discount_type = kwargs.get('discount_type') - if kwargs.get('discount_amount'): - self.discount_amount = Decimal(str(kwargs.get('discount_amount'))) + self.discount_type = kwargs.get("discount_type") + if kwargs.get("discount_amount"): + self.discount_amount = Decimal(str(kwargs.get("discount_amount"))) else: - self.discount_amount = Decimal('0') - self.discount_reason = kwargs.get('discount_reason', '').strip() if kwargs.get('discount_reason') else None - self.coupon_code = kwargs.get('coupon_code', '').strip().upper() if kwargs.get('coupon_code') else None - + self.discount_amount = Decimal("0") + self.discount_reason = kwargs.get("discount_reason", "").strip() if kwargs.get("discount_reason") else None + self.coupon_code = kwargs.get("coupon_code", "").strip().upper() if kwargs.get("coupon_code") else None + def __repr__(self): - return f'' - + return f"" + @property def is_draft(self): """Check if quote is in draft status""" - return self.status == 'draft' - + return self.status == "draft" + @property def is_sent(self): """Check if quote has been sent""" - return self.status == 'sent' - + return self.status == "sent" + @property def is_accepted(self): """Check if quote has been accepted""" - return self.status == 'accepted' - + return self.status == "accepted" + @property def is_rejected(self): """Check if quote has been rejected""" - return self.status == 'rejected' - + return self.status == "rejected" + @property def is_expired(self): """Check if quote has expired""" if not self.valid_until: return False return local_now().date() > self.valid_until - + @property def can_be_accepted(self): """Check if quote can be accepted (sent and not expired)""" - return self.status == 'sent' and not self.is_expired - + return self.status == "sent" and not self.is_expired + @property def has_project(self): """Check if quote has been converted to a project""" return self.project_id is not None - + def calculate_totals(self): """Calculate quote totals from items, applying discount if any""" items_total = sum(item.total_amount for item in self.items) self.subtotal = items_total - + # Apply discount if set - discount_value = Decimal('0') + discount_value = Decimal("0") if self.discount_type and self.discount_amount: - if self.discount_type == 'percentage': + if self.discount_type == "percentage": # Percentage discount applied to subtotal discount_value = self.subtotal * (self.discount_amount / 100) - elif self.discount_type == 'fixed': + elif self.discount_type == "fixed": # Fixed discount amount discount_value = min(self.discount_amount, self.subtotal) # Can't discount more than subtotal - + # Calculate subtotal after discount subtotal_after_discount = self.subtotal - discount_value - + # Calculate tax on discounted amount self.tax_amount = subtotal_after_discount * (self.tax_rate / 100) self.total_amount = subtotal_after_discount + self.tax_amount - + @property def discount_value(self): """Calculate the discount value based on type""" if not self.discount_type or not self.discount_amount: - return Decimal('0') - - if self.discount_type == 'percentage': + return Decimal("0") + + if self.discount_type == "percentage": return self.subtotal * (self.discount_amount / 100) - elif self.discount_type == 'fixed': + elif self.discount_type == "fixed": return min(self.discount_amount, self.subtotal) - return Decimal('0') - + return Decimal("0") + @property def subtotal_after_discount(self): """Get subtotal after discount is applied""" return self.subtotal - self.discount_value - + def calculate_due_date_from_payment_terms(self, issue_date=None): """Calculate due date based on payment terms - + Args: issue_date: Date to calculate from (defaults to today) - + Returns: Date object or None if payment terms cannot be parsed """ from datetime import timedelta from app.utils.timezone import local_now - + if not self.payment_terms: return None - + if issue_date is None: issue_date = local_now().date() - + payment_terms = self.payment_terms.strip().upper() - + # Parse common payment terms # "Net 30" -> 30 days # "Net 60" -> 60 days @@ -213,192 +227,193 @@ def calculate_due_date_from_payment_terms(self, issue_date=None): # "2/10 Net 30" -> 30 days (ignore early payment discount) # "Net 15" -> 15 days # etc. - - if 'DUE ON RECEIPT' in payment_terms or 'IMMEDIATE' in payment_terms: + + if "DUE ON RECEIPT" in payment_terms or "IMMEDIATE" in payment_terms: return issue_date - + # Extract number from "Net XX" pattern import re - match = re.search(r'NET\s*(\d+)', payment_terms) + + match = re.search(r"NET\s*(\d+)", payment_terms) if match: days = int(match.group(1)) return issue_date + timedelta(days=days) - + # Try to extract any number (fallback) - numbers = re.findall(r'\d+', payment_terms) + numbers = re.findall(r"\d+", payment_terms) if numbers: days = int(numbers[-1]) # Use last number found return issue_date + timedelta(days=days) - + return None - + def send(self): """Mark quote as sent""" - if self.requires_approval and self.approval_status != 'approved': + if self.requires_approval and self.approval_status != "approved": raise ValueError("Quote requires approval before it can be sent") - self.status = 'sent' + self.status = "sent" self.sent_at = local_now() self.updated_at = local_now() - + def request_approval(self): """Request approval for the quote""" if not self.requires_approval: raise ValueError("Quote does not require approval") - if self.approval_status == 'approved': + if self.approval_status == "approved": raise ValueError("Quote is already approved") - self.approval_status = 'pending' + self.approval_status = "pending" self.updated_at = local_now() - + def approve(self, user_id, notes=None): """Approve the quote""" if not self.requires_approval: raise ValueError("Quote does not require approval") - if self.approval_status != 'pending': + if self.approval_status != "pending": raise ValueError("Quote is not pending approval") - self.approval_status = 'approved' + self.approval_status = "approved" self.approved_by = user_id self.approved_at = local_now() if notes: - self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}' + self.notes = (self.notes or "") + f"\n\nApproval notes: {notes}" self.updated_at = local_now() - + def reject_approval(self, user_id, reason): """Reject the quote in approval workflow""" if not self.requires_approval: raise ValueError("Quote does not require approval") - if self.approval_status != 'pending': + if self.approval_status != "pending": raise ValueError("Quote is not pending approval") - self.approval_status = 'rejected' + self.approval_status = "rejected" self.rejected_by = user_id self.rejected_at = local_now() self.rejection_reason = reason self.updated_at = local_now() - + def accept(self, user_id, project_id=None): """Accept the quote and optionally link to a project""" if not self.can_be_accepted: raise ValueError("Quote cannot be accepted in its current state") - - self.status = 'accepted' + + self.status = "accepted" self.accepted_at = local_now() self.accepted_by = user_id if project_id: self.project_id = project_id self.updated_at = local_now() - + def reject(self): """Reject the quote""" - if self.status not in ['sent', 'draft']: + if self.status not in ["sent", "draft"]: raise ValueError("Quote cannot be rejected in its current state") - - self.status = 'rejected' + + self.status = "rejected" self.rejected_at = local_now() self.updated_at = local_now() - + def expire(self): """Mark quote as expired""" - if self.status == 'sent': - self.status = 'expired' + if self.status == "sent": + self.status = "expired" self.updated_at = local_now() - + def to_dict(self): """Convert quote to dictionary for API responses""" self.calculate_totals() # Ensure totals are up to date return { - 'id': self.id, - 'quote_number': self.quote_number, - 'client_id': self.client_id, - 'title': self.title, - 'description': self.description, - 'status': self.status, - 'subtotal': float(self.subtotal), - 'discount_type': self.discount_type, - 'discount_amount': float(self.discount_amount) if self.discount_amount else 0, - 'discount_value': float(self.discount_value), - 'discount_reason': self.discount_reason, - 'coupon_code': self.coupon_code, - 'subtotal_after_discount': float(self.subtotal_after_discount), - 'tax_rate': float(self.tax_rate), - 'tax_amount': float(self.tax_amount), - 'total_amount': float(self.total_amount), - 'currency_code': self.currency_code, - 'valid_until': self.valid_until.isoformat() if self.valid_until else None, - 'sent_at': self.sent_at.isoformat() if self.sent_at else None, - 'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None, - 'rejected_at': self.rejected_at.isoformat() if self.rejected_at else None, - 'project_id': self.project_id, - 'created_by': self.created_by, - 'accepted_by': self.accepted_by, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'notes': self.notes, - 'terms': self.terms, - 'visible_to_client': self.visible_to_client, - 'template_id': self.template_id, - 'is_draft': self.is_draft, - 'is_sent': self.is_sent, - 'is_accepted': self.is_accepted, - 'is_rejected': self.is_rejected, - 'is_expired': self.is_expired, - 'can_be_accepted': self.can_be_accepted, - 'has_project': self.has_project, - 'items': [item.to_dict() for item in self.items] + "id": self.id, + "quote_number": self.quote_number, + "client_id": self.client_id, + "title": self.title, + "description": self.description, + "status": self.status, + "subtotal": float(self.subtotal), + "discount_type": self.discount_type, + "discount_amount": float(self.discount_amount) if self.discount_amount else 0, + "discount_value": float(self.discount_value), + "discount_reason": self.discount_reason, + "coupon_code": self.coupon_code, + "subtotal_after_discount": float(self.subtotal_after_discount), + "tax_rate": float(self.tax_rate), + "tax_amount": float(self.tax_amount), + "total_amount": float(self.total_amount), + "currency_code": self.currency_code, + "valid_until": self.valid_until.isoformat() if self.valid_until else None, + "sent_at": self.sent_at.isoformat() if self.sent_at else None, + "accepted_at": self.accepted_at.isoformat() if self.accepted_at else None, + "rejected_at": self.rejected_at.isoformat() if self.rejected_at else None, + "project_id": self.project_id, + "created_by": self.created_by, + "accepted_by": self.accepted_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "notes": self.notes, + "terms": self.terms, + "visible_to_client": self.visible_to_client, + "template_id": self.template_id, + "is_draft": self.is_draft, + "is_sent": self.is_sent, + "is_accepted": self.is_accepted, + "is_rejected": self.is_rejected, + "is_expired": self.is_expired, + "can_be_accepted": self.can_be_accepted, + "has_project": self.has_project, + "items": [item.to_dict() for item in self.items], } - + @classmethod def generate_quote_number(cls): """Generate a unique quote number""" # Format: QUO-YYYYMMDD-XXX today = local_now() - date_prefix = today.strftime('%Y%m%d') - + date_prefix = today.strftime("%Y%m%d") + # Find the next available number for today - existing = cls.query.filter( - cls.quote_number.like(f'QUO-{date_prefix}-%') - ).order_by(cls.quote_number.desc()).first() - + existing = ( + cls.query.filter(cls.quote_number.like(f"QUO-{date_prefix}-%")).order_by(cls.quote_number.desc()).first() + ) + if existing: # Extract the number part and increment try: - last_num = int(existing.quote_number.split('-')[-1]) + last_num = int(existing.quote_number.split("-")[-1]) next_num = last_num + 1 except (ValueError, IndexError): next_num = 1 else: next_num = 1 - - return f'QUO-{date_prefix}-{next_num:03d}' + + return f"QUO-{date_prefix}-{next_num:03d}" class QuoteItem(db.Model): """Quote line item model""" - - __tablename__ = 'quote_items' - + + __tablename__ = "quote_items" + id = db.Column(db.Integer, primary_key=True) - quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id'), nullable=False, index=True) - + quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id"), nullable=False, index=True) + # Item details description = db.Column(db.String(500), nullable=False) quantity = db.Column(db.Numeric(10, 2), nullable=False, default=1) unit_price = db.Column(db.Numeric(10, 2), nullable=False) total_amount = db.Column(db.Numeric(10, 2), nullable=False) - + # Optional fields unit = db.Column(db.String(20), nullable=True) # 'hours', 'days', 'items', etc. - + # Inventory integration - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=True, index=True) - warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=True) + stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True) + warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=True) is_stock_item = db.Column(db.Boolean, default=False, nullable=False) - + # Metadata created_at = db.Column(db.DateTime, default=local_now, nullable=False) - + # Relationships - stock_item = db.relationship('StockItem', foreign_keys=[stock_item_id], lazy='joined') - warehouse = db.relationship('Warehouse', foreign_keys=[warehouse_id], lazy='joined') - + stock_item = db.relationship("StockItem", foreign_keys=[stock_item_id], lazy="joined") + warehouse = db.relationship("Warehouse", foreign_keys=[warehouse_id], lazy="joined") + def __init__(self, quote_id, description, quantity, unit_price, unit=None, stock_item_id=None, warehouse_id=None): self.quote_id = quote_id self.description = description.strip() @@ -409,32 +424,32 @@ def __init__(self, quote_id, description, quantity, unit_price, unit=None, stock self.stock_item_id = stock_item_id self.warehouse_id = warehouse_id self.is_stock_item = stock_item_id is not None - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert quote item to dictionary""" return { - 'id': self.id, - 'quote_id': self.quote_id, - 'description': self.description, - 'quantity': float(self.quantity), - 'unit_price': float(self.unit_price), - 'total_amount': float(self.total_amount), - 'unit': self.unit, - 'stock_item_id': self.stock_item_id, - 'warehouse_id': self.warehouse_id, - 'is_stock_item': self.is_stock_item, - 'created_at': self.created_at.isoformat() if self.created_at else None + "id": self.id, + "quote_id": self.quote_id, + "description": self.description, + "quantity": float(self.quantity), + "unit_price": float(self.unit_price), + "total_amount": float(self.total_amount), + "unit": self.unit, + "stock_item_id": self.stock_item_id, + "warehouse_id": self.warehouse_id, + "is_stock_item": self.is_stock_item, + "created_at": self.created_at.isoformat() if self.created_at else None, } class QuotePDFTemplate(db.Model): """Model for storing quote PDF templates by page size""" - - __tablename__ = 'quote_pdf_templates' - + + __tablename__ = "quote_pdf_templates" + id = db.Column(db.Integer, primary_key=True) page_size = db.Column(db.String(20), nullable=False, unique=True) # A4, Letter, A3, etc. template_html = db.Column(db.Text, nullable=True) @@ -443,42 +458,41 @@ class QuotePDFTemplate(db.Model): is_default = db.Column(db.Boolean, default=False, nullable=False) created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Standard page sizes and their dimensions in mm (for reference) PAGE_SIZES = { - 'A4': {'width': 210, 'height': 297}, - 'Letter': {'width': 216, 'height': 279}, - 'Legal': {'width': 216, 'height': 356}, - 'A3': {'width': 297, 'height': 420}, - 'A5': {'width': 148, 'height': 210}, - 'Tabloid': {'width': 279, 'height': 432}, + "A4": {"width": 210, "height": 297}, + "Letter": {"width": 216, "height": 279}, + "Legal": {"width": 216, "height": 356}, + "A3": {"width": 297, "height": 420}, + "A5": {"width": 148, "height": 210}, + "Tabloid": {"width": 279, "height": 432}, } - + def __repr__(self): - return f'' - + return f"" + @classmethod - def get_template(cls, page_size='A4'): + def get_template(cls, page_size="A4"): """Get template for a specific page size, creating default if needed""" template = cls.query.filter_by(page_size=page_size).first() if not template: - template = cls(page_size=page_size, is_default=(page_size == 'A4')) + template = cls(page_size=page_size, is_default=(page_size == "A4")) db.session.add(template) db.session.commit() return template - + @classmethod def get_all_templates(cls): """Get all templates""" return cls.query.order_by(cls.page_size).all() - + @classmethod def get_default_template(cls): """Get the default template""" template = cls.query.filter_by(is_default=True).first() if not template: - template = cls.get_template('A4') + template = cls.get_template("A4") template.is_default = True db.session.commit() return template - diff --git a/app/models/quote_attachment.py b/app/models/quote_attachment.py index f2daffcc..cd229daf 100644 --- a/app/models/quote_attachment.py +++ b/app/models/quote_attachment.py @@ -3,6 +3,7 @@ from app.utils.timezone import now_in_app_timezone import os + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) @@ -10,31 +11,33 @@ def local_now(): class QuoteAttachment(db.Model): """Model for quote file attachments""" - - __tablename__ = 'quote_attachments' - + + __tablename__ = "quote_attachments" + id = db.Column(db.Integer, primary_key=True) - quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id', ondelete='CASCADE'), nullable=False, index=True) - + quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False, index=True) + # File information filename = db.Column(db.String(255), nullable=False) original_filename = db.Column(db.String(255), nullable=False) file_path = db.Column(db.String(500), nullable=False) file_size = db.Column(db.Integer, nullable=False) # Size in bytes mime_type = db.Column(db.String(100), nullable=True) - + # Metadata description = db.Column(db.Text, nullable=True) - is_visible_to_client = db.Column(db.Boolean, default=False, nullable=False) # Whether attachment is visible in client portal - + is_visible_to_client = db.Column( + db.Boolean, default=False, nullable=False + ) # Whether attachment is visible in client portal + # Upload information - uploaded_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + uploaded_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) uploaded_at = db.Column(db.DateTime, default=local_now, nullable=False) - + # Relationships - quote = db.relationship('Quote', backref='attachments') - uploader = db.relationship('User', backref='uploaded_quote_attachments') - + quote = db.relationship("Quote", backref="attachments") + uploader = db.relationship("User", backref="uploaded_quote_attachments") + def __init__(self, quote_id, filename, original_filename, file_path, file_size, uploaded_by, **kwargs): self.quote_id = quote_id self.filename = filename @@ -42,23 +45,23 @@ def __init__(self, quote_id, filename, original_filename, file_path, file_size, self.file_path = file_path self.file_size = file_size self.uploaded_by = uploaded_by - self.mime_type = kwargs.get('mime_type') - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.is_visible_to_client = kwargs.get('is_visible_to_client', False) - + self.mime_type = kwargs.get("mime_type") + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.is_visible_to_client = kwargs.get("is_visible_to_client", False) + def __repr__(self): - return f'' - + return f"" + @property def file_size_mb(self): """Get file size in megabytes""" return round(self.file_size / (1024 * 1024), 2) - + @property def file_size_kb(self): """Get file size in kilobytes""" return round(self.file_size / 1024, 2) - + @property def file_size_display(self): """Get human-readable file size""" @@ -68,62 +71,62 @@ def file_size_display(self): return f"{self.file_size_kb} KB" else: return f"{self.file_size_mb} MB" - + @property def file_extension(self): """Get file extension""" return os.path.splitext(self.original_filename)[1].lower() - + @property def is_image(self): """Check if file is an image""" - return self.file_extension in ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'] - + return self.file_extension in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"] + @property def is_pdf(self): """Check if file is a PDF""" - return self.file_extension == '.pdf' - + return self.file_extension == ".pdf" + @property def is_document(self): """Check if file is a document""" - return self.file_extension in ['.doc', '.docx', '.txt', '.rtf'] - + return self.file_extension in [".doc", ".docx", ".txt", ".rtf"] + @property def download_url(self): """Get URL for downloading the attachment""" from flask import url_for - return url_for('quotes.download_attachment', attachment_id=self.id) - + + return url_for("quotes.download_attachment", attachment_id=self.id) + def to_dict(self): """Convert attachment to dictionary for API responses""" return { - 'id': self.id, - 'quote_id': self.quote_id, - 'filename': self.filename, - 'original_filename': self.original_filename, - 'file_size': self.file_size, - 'file_size_display': self.file_size_display, - 'mime_type': self.mime_type, - 'description': self.description, - 'is_visible_to_client': self.is_visible_to_client, - 'uploaded_by': self.uploaded_by, - 'uploader': self.uploader.username if self.uploader else None, - 'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None, - 'file_extension': self.file_extension, - 'is_image': self.is_image, - 'is_pdf': self.is_pdf, - 'is_document': self.is_document, - 'download_url': self.download_url + "id": self.id, + "quote_id": self.quote_id, + "filename": self.filename, + "original_filename": self.original_filename, + "file_size": self.file_size, + "file_size_display": self.file_size_display, + "mime_type": self.mime_type, + "description": self.description, + "is_visible_to_client": self.is_visible_to_client, + "uploaded_by": self.uploaded_by, + "uploader": self.uploader.username if self.uploader else None, + "uploaded_at": self.uploaded_at.isoformat() if self.uploaded_at else None, + "file_extension": self.file_extension, + "is_image": self.is_image, + "is_pdf": self.is_pdf, + "is_document": self.is_document, + "download_url": self.download_url, } - + @classmethod def get_quote_attachments(cls, quote_id, include_client_visible=True): """Get all attachments for a quote""" query = cls.query.filter_by(quote_id=quote_id) - + if not include_client_visible: query = query.filter_by(is_visible_to_client=False) - - return query.order_by(cls.uploaded_at.desc()).all() + return query.order_by(cls.uploaded_at.desc()).all() diff --git a/app/models/quote_template.py b/app/models/quote_template.py index 4fd98974..f9a721b8 100644 --- a/app/models/quote_template.py +++ b/app/models/quote_template.py @@ -3,6 +3,7 @@ from app.utils.timezone import now_in_app_timezone import json + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) @@ -10,59 +11,61 @@ def local_now(): class QuoteTemplate(db.Model): """Model for reusable quote templates/presets""" - - __tablename__ = 'quote_templates' - + + __tablename__ = "quote_templates" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False, index=True) description = db.Column(db.Text, nullable=True) - + # Template content (stored as JSON for flexibility) template_data = db.Column(db.Text, nullable=True) # JSON string with quote configuration - + # Common fields that can be preset default_tax_rate = db.Column(db.Numeric(5, 2), nullable=True, default=0) - default_currency_code = db.Column(db.String(3), nullable=True, default='EUR') + default_currency_code = db.Column(db.String(3), nullable=True, default="EUR") default_payment_terms = db.Column(db.String(100), nullable=True) default_terms = db.Column(db.Text, nullable=True) # Terms and conditions default_valid_until_days = db.Column(db.Integer, nullable=True, default=30) # Days until expiration - + # Approval workflow defaults default_requires_approval = db.Column(db.Boolean, default=False, nullable=False) default_approval_level = db.Column(db.Integer, nullable=True, default=1) - + # Default items (stored as JSON) default_items = db.Column(db.Text, nullable=True) # JSON array of quote items - + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) is_public = db.Column(db.Boolean, default=False, nullable=False) # Whether template is available to all users usage_count = db.Column(db.Integer, default=0, nullable=False) # Track how many times template was used - + created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Relationships - creator = db.relationship('User', foreign_keys=[created_by], backref='created_quote_templates') - + creator = db.relationship("User", foreign_keys=[created_by], backref="created_quote_templates") + def __init__(self, name, created_by, **kwargs): self.name = name.strip() self.created_by = created_by - self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None - self.default_tax_rate = kwargs.get('default_tax_rate', 0) - self.default_currency_code = kwargs.get('default_currency_code', 'EUR') - self.default_payment_terms = kwargs.get('default_payment_terms', '').strip() if kwargs.get('default_payment_terms') else None - self.default_terms = kwargs.get('default_terms', '').strip() if kwargs.get('default_terms') else None - self.default_valid_until_days = kwargs.get('default_valid_until_days', 30) - self.default_requires_approval = kwargs.get('default_requires_approval', False) - self.default_approval_level = kwargs.get('default_approval_level', 1) - self.is_public = kwargs.get('is_public', False) - self.default_items = kwargs.get('default_items') # JSON string - self.template_data = kwargs.get('template_data') # JSON string - + self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None + self.default_tax_rate = kwargs.get("default_tax_rate", 0) + self.default_currency_code = kwargs.get("default_currency_code", "EUR") + self.default_payment_terms = ( + kwargs.get("default_payment_terms", "").strip() if kwargs.get("default_payment_terms") else None + ) + self.default_terms = kwargs.get("default_terms", "").strip() if kwargs.get("default_terms") else None + self.default_valid_until_days = kwargs.get("default_valid_until_days", 30) + self.default_requires_approval = kwargs.get("default_requires_approval", False) + self.default_approval_level = kwargs.get("default_approval_level", 1) + self.is_public = kwargs.get("is_public", False) + self.default_items = kwargs.get("default_items") # JSON string + self.template_data = kwargs.get("template_data") # JSON string + def __repr__(self): - return f'' - + return f"" + @property def items_list(self): """Get default items as a list""" @@ -72,7 +75,7 @@ def items_list(self): return json.loads(self.default_items) except (json.JSONDecodeError, TypeError): return [] - + @items_list.setter def items_list(self, value): """Set default items from a list""" @@ -80,7 +83,7 @@ def items_list(self, value): self.default_items = json.dumps(value) else: self.default_items = None - + @property def data_dict(self): """Get template data as a dictionary""" @@ -90,7 +93,7 @@ def data_dict(self): return json.loads(self.template_data) except (json.JSONDecodeError, TypeError): return {} - + @data_dict.setter def data_dict(self, value): """Set template data from a dictionary""" @@ -98,12 +101,12 @@ def data_dict(self, value): self.template_data = json.dumps(value) else: self.template_data = None - + def increment_usage(self): """Increment usage count""" self.usage_count += 1 self.updated_at = local_now() - + def apply_to_quote(self, quote): """Apply template settings to a quote object""" quote.tax_rate = self.default_tax_rate or quote.tax_rate @@ -112,63 +115,60 @@ def apply_to_quote(self, quote): quote.terms = self.default_terms or quote.terms quote.requires_approval = self.default_requires_approval quote.approval_level = self.default_approval_level or 1 - + # Apply default items items = self.items_list if items: from app.models import QuoteItem from decimal import Decimal + for item_data in items: item = QuoteItem( quote_id=quote.id, - description=item_data.get('description', ''), - quantity=Decimal(str(item_data.get('quantity', 1))), - unit_price=Decimal(str(item_data.get('unit_price', 0))), - unit=item_data.get('unit') + description=item_data.get("description", ""), + quantity=Decimal(str(item_data.get("quantity", 1))), + unit_price=Decimal(str(item_data.get("unit_price", 0))), + unit=item_data.get("unit"), ) db.session.add(item) - + def to_dict(self): """Convert template to dictionary for API responses""" return { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'default_tax_rate': float(self.default_tax_rate) if self.default_tax_rate else 0, - 'default_currency_code': self.default_currency_code, - 'default_payment_terms': self.default_payment_terms, - 'default_terms': self.default_terms, - 'default_valid_until_days': self.default_valid_until_days, - 'default_requires_approval': self.default_requires_approval, - 'default_approval_level': self.default_approval_level, - 'default_items': self.items_list, - 'template_data': self.data_dict, - 'is_public': self.is_public, - 'usage_count': self.usage_count, - 'created_by': self.created_by, - 'creator': self.creator.username if self.creator else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "name": self.name, + "description": self.description, + "default_tax_rate": float(self.default_tax_rate) if self.default_tax_rate else 0, + "default_currency_code": self.default_currency_code, + "default_payment_terms": self.default_payment_terms, + "default_terms": self.default_terms, + "default_valid_until_days": self.default_valid_until_days, + "default_requires_approval": self.default_requires_approval, + "default_approval_level": self.default_approval_level, + "default_items": self.items_list, + "template_data": self.data_dict, + "is_public": self.is_public, + "usage_count": self.usage_count, + "created_by": self.created_by, + "creator": self.creator.username if self.creator else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_user_templates(cls, user_id, include_public=True): """Get templates available to a user""" query = cls.query.filter( - db.or_( - cls.created_by == user_id, - cls.is_public == True if include_public else db.false() - ) + db.or_(cls.created_by == user_id, cls.is_public == True if include_public else db.false()) ) return query.order_by(cls.usage_count.desc(), cls.name.asc()).all() - + @classmethod def get_public_templates(cls): """Get all public templates""" return cls.query.filter_by(is_public=True).order_by(cls.usage_count.desc(), cls.name.asc()).all() - + @classmethod def get_popular_templates(cls, limit=10): """Get most used templates""" return cls.query.order_by(cls.usage_count.desc()).limit(limit).all() - diff --git a/app/models/quote_version.py b/app/models/quote_version.py index cc25cbf1..1a6d6268 100644 --- a/app/models/quote_version.py +++ b/app/models/quote_version.py @@ -3,6 +3,7 @@ from app.utils.timezone import now_in_app_timezone import json + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" return now_in_app_timezone().replace(tzinfo=None) @@ -10,39 +11,39 @@ def local_now(): class QuoteVersion(db.Model): """Model for tracking quote version history""" - - __tablename__ = 'quote_versions' - + + __tablename__ = "quote_versions" + id = db.Column(db.Integer, primary_key=True) - quote_id = db.Column(db.Integer, db.ForeignKey('quotes.id', ondelete='CASCADE'), nullable=False, index=True) + quote_id = db.Column(db.Integer, db.ForeignKey("quotes.id", ondelete="CASCADE"), nullable=False, index=True) version_number = db.Column(db.Integer, nullable=False) # 1, 2, 3, etc. - + # Snapshot of quote data at this version (stored as JSON) quote_data = db.Column(db.Text, nullable=False) # JSON string with complete quote state - + # Change information - changed_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + changed_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) changed_at = db.Column(db.DateTime, default=local_now, nullable=False) change_summary = db.Column(db.String(500), nullable=True) # Brief description of changes - + # What changed (for quick reference) fields_changed = db.Column(db.String(500), nullable=True) # Comma-separated list of changed fields - + # Relationships - quote = db.relationship('Quote', backref='versions') - changer = db.relationship('User', foreign_keys=[changed_by], backref='quote_version_changes') - + quote = db.relationship("Quote", backref="versions") + changer = db.relationship("User", foreign_keys=[changed_by], backref="quote_version_changes") + def __init__(self, quote_id, version_number, quote_data, changed_by, **kwargs): self.quote_id = quote_id self.version_number = version_number self.quote_data = quote_data if isinstance(quote_data, str) else json.dumps(quote_data) self.changed_by = changed_by - self.change_summary = kwargs.get('change_summary', '').strip() if kwargs.get('change_summary') else None - self.fields_changed = kwargs.get('fields_changed', '').strip() if kwargs.get('fields_changed') else None - + self.change_summary = kwargs.get("change_summary", "").strip() if kwargs.get("change_summary") else None + self.fields_changed = kwargs.get("fields_changed", "").strip() if kwargs.get("fields_changed") else None + def __repr__(self): - return f'' - + return f"" + @property def data_dict(self): """Get quote data as a dictionary""" @@ -50,76 +51,78 @@ def data_dict(self): return json.loads(self.quote_data) except (json.JSONDecodeError, TypeError): return {} - + def to_dict(self): """Convert version to dictionary for API responses""" return { - 'id': self.id, - 'quote_id': self.quote_id, - 'version_number': self.version_number, - 'quote_data': self.data_dict, - 'changed_by': self.changed_by, - 'changer': self.changer.username if self.changer else None, - 'changed_at': self.changed_at.isoformat() if self.changed_at else None, - 'change_summary': self.change_summary, - 'fields_changed': self.fields_changed.split(',') if self.fields_changed else [] + "id": self.id, + "quote_id": self.quote_id, + "version_number": self.version_number, + "quote_data": self.data_dict, + "changed_by": self.changed_by, + "changer": self.changer.username if self.changer else None, + "changed_at": self.changed_at.isoformat() if self.changed_at else None, + "change_summary": self.change_summary, + "fields_changed": self.fields_changed.split(",") if self.fields_changed else [], } - + @classmethod def create_version(cls, quote, changed_by, change_summary=None, fields_changed=None): """Create a new version snapshot of a quote""" # Get current version number last_version = cls.query.filter_by(quote_id=quote.id).order_by(cls.version_number.desc()).first() version_number = (last_version.version_number + 1) if last_version else 1 - + # Create snapshot of quote data quote_data = { - 'title': quote.title, - 'description': quote.description, - 'status': quote.status, - 'subtotal': float(quote.subtotal), - 'tax_rate': float(quote.tax_rate), - 'tax_amount': float(quote.tax_amount), - 'total_amount': float(quote.total_amount), - 'currency_code': quote.currency_code, - 'discount_type': quote.discount_type, - 'discount_amount': float(quote.discount_amount) if quote.discount_amount else None, - 'discount_reason': quote.discount_reason, - 'coupon_code': quote.coupon_code, - 'payment_terms': quote.payment_terms, - 'valid_until': quote.valid_until.isoformat() if quote.valid_until else None, - 'notes': quote.notes, - 'terms': quote.terms, - 'visible_to_client': quote.visible_to_client, - 'requires_approval': quote.requires_approval, - 'approval_status': quote.approval_status, - 'items': [{ - 'description': item.description, - 'quantity': float(item.quantity), - 'unit_price': float(item.unit_price), - 'unit': item.unit - } for item in quote.items] + "title": quote.title, + "description": quote.description, + "status": quote.status, + "subtotal": float(quote.subtotal), + "tax_rate": float(quote.tax_rate), + "tax_amount": float(quote.tax_amount), + "total_amount": float(quote.total_amount), + "currency_code": quote.currency_code, + "discount_type": quote.discount_type, + "discount_amount": float(quote.discount_amount) if quote.discount_amount else None, + "discount_reason": quote.discount_reason, + "coupon_code": quote.coupon_code, + "payment_terms": quote.payment_terms, + "valid_until": quote.valid_until.isoformat() if quote.valid_until else None, + "notes": quote.notes, + "terms": quote.terms, + "visible_to_client": quote.visible_to_client, + "requires_approval": quote.requires_approval, + "approval_status": quote.approval_status, + "items": [ + { + "description": item.description, + "quantity": float(item.quantity), + "unit_price": float(item.unit_price), + "unit": item.unit, + } + for item in quote.items + ], } - + version = cls( quote_id=quote.id, version_number=version_number, quote_data=json.dumps(quote_data), changed_by=changed_by, change_summary=change_summary, - fields_changed=','.join(fields_changed) if fields_changed else None + fields_changed=",".join(fields_changed) if fields_changed else None, ) - + db.session.add(version) return version - + @classmethod def get_quote_versions(cls, quote_id): """Get all versions for a quote""" return cls.query.filter_by(quote_id=quote_id).order_by(cls.version_number.desc()).all() - + @classmethod def get_latest_version(cls, quote_id): """Get the latest version of a quote""" return cls.query.filter_by(quote_id=quote_id).order_by(cls.version_number.desc()).first() - diff --git a/app/models/rate_override.py b/app/models/rate_override.py index ce361de4..030b4a4b 100644 --- a/app/models/rate_override.py +++ b/app/models/rate_override.py @@ -14,11 +14,11 @@ class RateOverride(db.Model): - 0 """ - __tablename__ = 'rate_overrides' + __tablename__ = "rate_overrides" id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) hourly_rate = db.Column(db.Numeric(9, 2), nullable=False) effective_from = db.Column(db.Date, nullable=True) effective_to = db.Column(db.Date, nullable=True) @@ -27,19 +27,22 @@ class RateOverride(db.Model): updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) __table_args__ = ( - db.UniqueConstraint('project_id', 'user_id', 'effective_from', name='ux_rate_override_unique_window'), + db.UniqueConstraint("project_id", "user_id", "effective_from", name="ux_rate_override_unique_window"), ) @classmethod def resolve_rate(cls, project, user_id=None, on_date=None): """Resolve effective hourly rate for a project/user at a given date.""" if not project: - return Decimal('0') + return Decimal("0") # Step 1: specific user override q = cls.query.filter_by(project_id=project.id, user_id=user_id) if on_date: - q = q.filter((cls.effective_from.is_(None) | (cls.effective_from <= on_date)) & (cls.effective_to.is_(None) | (cls.effective_to >= on_date))) + q = q.filter( + (cls.effective_from.is_(None) | (cls.effective_from <= on_date)) + & (cls.effective_to.is_(None) | (cls.effective_to >= on_date)) + ) user_ovr = q.order_by(cls.effective_from.desc().nullslast()).first() if user_ovr: return Decimal(user_ovr.hourly_rate) @@ -47,7 +50,10 @@ def resolve_rate(cls, project, user_id=None, on_date=None): # Step 2: project-level override q = cls.query.filter_by(project_id=project.id, user_id=None) if on_date: - q = q.filter((cls.effective_from.is_(None) | (cls.effective_from <= on_date)) & (cls.effective_to.is_(None) | (cls.effective_to >= on_date))) + q = q.filter( + (cls.effective_from.is_(None) | (cls.effective_from <= on_date)) + & (cls.effective_to.is_(None) | (cls.effective_to >= on_date)) + ) proj_ovr = q.order_by(cls.effective_from.desc().nullslast()).first() if proj_ovr: return Decimal(proj_ovr.hourly_rate) @@ -63,6 +69,4 @@ def resolve_rate(cls, project, user_id=None, on_date=None): except Exception: pass - return Decimal('0') - - + return Decimal("0") diff --git a/app/models/recurring_block.py b/app/models/recurring_block.py index e550faee..f364fadf 100644 --- a/app/models/recurring_block.py +++ b/app/models/recurring_block.py @@ -10,23 +10,23 @@ class RecurringBlock(db.Model): templates into concrete `TimeEntry` rows. """ - __tablename__ = 'recurring_blocks' + __tablename__ = "recurring_blocks" id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) - task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) name = db.Column(db.String(200), nullable=False) # Scheduling fields # 'weekly' for now; room to add 'daily', 'monthly' later - recurrence = db.Column(db.String(20), nullable=False, default='weekly') + recurrence = db.Column(db.String(20), nullable=False, default="weekly") # Weekdays CSV: e.g., "mon,tue,wed"; canonical lower 3-letter names weekdays = db.Column(db.String(50), nullable=True) # Time window in local time: "HH:MM" strings start_time_local = db.Column(db.String(5), nullable=False) # 09:00 - end_time_local = db.Column(db.String(5), nullable=False) # 11:00 + end_time_local = db.Column(db.String(5), nullable=False) # 11:00 # Activation window starts_on = db.Column(db.Date, nullable=True) @@ -46,24 +46,22 @@ class RecurringBlock(db.Model): def to_dict(self): return { - 'id': self.id, - 'user_id': self.user_id, - 'project_id': self.project_id, - 'task_id': self.task_id, - 'name': self.name, - 'recurrence': self.recurrence, - 'weekdays': self.weekdays, - 'start_time_local': self.start_time_local, - 'end_time_local': self.end_time_local, - 'starts_on': self.starts_on.isoformat() if self.starts_on else None, - 'ends_on': self.ends_on.isoformat() if self.ends_on else None, - 'is_active': self.is_active, - 'notes': self.notes, - 'tags': self.tags, - 'billable': self.billable, - 'last_generated_at': self.last_generated_at.isoformat() if self.last_generated_at else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "task_id": self.task_id, + "name": self.name, + "recurrence": self.recurrence, + "weekdays": self.weekdays, + "start_time_local": self.start_time_local, + "end_time_local": self.end_time_local, + "starts_on": self.starts_on.isoformat() if self.starts_on else None, + "ends_on": self.ends_on.isoformat() if self.ends_on else None, + "is_active": self.is_active, + "notes": self.notes, + "tags": self.tags, + "billable": self.billable, + "last_generated_at": self.last_generated_at.isoformat() if self.last_generated_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - - diff --git a/app/models/recurring_invoice.py b/app/models/recurring_invoice.py index 365475a9..3cd2d4ad 100644 --- a/app/models/recurring_invoice.py +++ b/app/models/recurring_invoice.py @@ -6,51 +6,53 @@ class RecurringInvoice(db.Model): """Recurring invoice template model for automated billing""" - - __tablename__ = 'recurring_invoices' - + + __tablename__ = "recurring_invoices" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False) # Template name/description - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True) - + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=False, index=True) + # Recurrence settings frequency = db.Column(db.String(20), nullable=False) # 'daily', 'weekly', 'monthly', 'yearly' interval = db.Column(db.Integer, nullable=False, default=1) # Every N periods (e.g., every 2 weeks) next_run_date = db.Column(db.Date, nullable=False) # Next date to generate invoice end_date = db.Column(db.Date, nullable=True) # Optional end date for recurrence - + # Invoice template settings (copied to generated invoices) client_name = db.Column(db.String(200), nullable=False) client_email = db.Column(db.String(200), nullable=True) client_address = db.Column(db.Text, nullable=True) due_date_days = db.Column(db.Integer, nullable=False, default=30) # Days from issue date to due date tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') + currency_code = db.Column(db.String(3), nullable=False, default="EUR") notes = db.Column(db.Text, nullable=True) terms = db.Column(db.Text, nullable=True) - template_id = db.Column(db.Integer, db.ForeignKey('invoice_templates.id'), nullable=True, index=True) - + template_id = db.Column(db.Integer, db.ForeignKey("invoice_templates.id"), nullable=True, index=True) + # Auto-send settings auto_send = db.Column(db.Boolean, nullable=False, default=False) # Automatically send via email when generated auto_include_time_entries = db.Column(db.Boolean, nullable=False, default=True) # Include unbilled time entries - + # Status is_active = db.Column(db.Boolean, nullable=False, default=True) - + # Metadata - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) last_generated_at = db.Column(db.DateTime, nullable=True) # Last time an invoice was generated - + # Relationships - project = db.relationship('Project', backref='recurring_invoices') - client = db.relationship('Client', backref='recurring_invoices') - creator = db.relationship('User', backref='created_recurring_invoices') - template = db.relationship('InvoiceTemplate', backref='recurring_invoices') - generated_invoices = db.relationship('Invoice', backref='recurring_invoice_template', lazy='dynamic', foreign_keys='[Invoice.recurring_invoice_id]') - + project = db.relationship("Project", backref="recurring_invoices") + client = db.relationship("Client", backref="recurring_invoices") + creator = db.relationship("User", backref="created_recurring_invoices") + template = db.relationship("InvoiceTemplate", backref="recurring_invoices") + generated_invoices = db.relationship( + "Invoice", backref="recurring_invoice_template", lazy="dynamic", foreign_keys="[Invoice.recurring_invoice_id]" + ) + def __init__(self, name, project_id, client_id, frequency, next_run_date, created_by, **kwargs): self.name = name self.project_id = project_id @@ -58,74 +60,74 @@ def __init__(self, name, project_id, client_id, frequency, next_run_date, create self.frequency = frequency self.next_run_date = next_run_date self.created_by = created_by - + # Set optional fields - self.interval = kwargs.get('interval', 1) - self.end_date = kwargs.get('end_date') - self.client_name = kwargs.get('client_name', '') - self.client_email = kwargs.get('client_email') - self.client_address = kwargs.get('client_address') - self.due_date_days = kwargs.get('due_date_days', 30) - self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0))) - self.currency_code = kwargs.get('currency_code', 'EUR') - self.notes = kwargs.get('notes') - self.terms = kwargs.get('terms') - self.template_id = kwargs.get('template_id') - self.auto_send = kwargs.get('auto_send', False) - self.auto_include_time_entries = kwargs.get('auto_include_time_entries', True) - self.is_active = kwargs.get('is_active', True) - + self.interval = kwargs.get("interval", 1) + self.end_date = kwargs.get("end_date") + self.client_name = kwargs.get("client_name", "") + self.client_email = kwargs.get("client_email") + self.client_address = kwargs.get("client_address") + self.due_date_days = kwargs.get("due_date_days", 30) + self.tax_rate = Decimal(str(kwargs.get("tax_rate", 0))) + self.currency_code = kwargs.get("currency_code", "EUR") + self.notes = kwargs.get("notes") + self.terms = kwargs.get("terms") + self.template_id = kwargs.get("template_id") + self.auto_send = kwargs.get("auto_send", False) + self.auto_include_time_entries = kwargs.get("auto_include_time_entries", True) + self.is_active = kwargs.get("is_active", True) + def __repr__(self): - return f'' - + return f"" + def calculate_next_run_date(self, from_date=None): """Calculate the next run date based on frequency and interval""" if from_date is None: from_date = datetime.utcnow().date() - - if self.frequency == 'daily': + + if self.frequency == "daily": return from_date + timedelta(days=self.interval) - elif self.frequency == 'weekly': + elif self.frequency == "weekly": return from_date + timedelta(weeks=self.interval) - elif self.frequency == 'monthly': + elif self.frequency == "monthly": return from_date + relativedelta(months=self.interval) - elif self.frequency == 'yearly': + elif self.frequency == "yearly": return from_date + relativedelta(years=self.interval) else: raise ValueError(f"Invalid frequency: {self.frequency}") - + def should_generate_today(self): """Check if invoice should be generated today""" if not self.is_active: return False - + today = datetime.utcnow().date() - + # Check if we've reached the end date if self.end_date and today > self.end_date: return False - + # Check if it's time to generate return today >= self.next_run_date - + def generate_invoice(self): """Generate an invoice from this recurring template""" from app.models import Invoice, InvoiceItem, TimeEntry, Settings - + if not self.should_generate_today(): return None - + # Get settings for currency settings = Settings.get_settings() - currency_code = self.currency_code or (settings.currency if settings else 'EUR') - + currency_code = self.currency_code or (settings.currency if settings else "EUR") + # Calculate dates issue_date = datetime.utcnow().date() due_date = issue_date + timedelta(days=self.due_date_days) - + # Generate invoice number invoice_number = Invoice.generate_invoice_number() - + # Create invoice invoice = Invoice( invoice_number=invoice_number, @@ -141,23 +143,25 @@ def generate_invoice(self): terms=self.terms, currency_code=currency_code, template_id=self.template_id, - issue_date=issue_date + issue_date=issue_date, ) - + # Link to recurring invoice template invoice.recurring_invoice_id = self.id - + db.session.add(invoice) - + # Auto-include time entries if enabled if self.auto_include_time_entries: # Get unbilled time entries for this project - time_entries = TimeEntry.query.filter( - TimeEntry.project_id == self.project_id, - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True - ).order_by(TimeEntry.start_time.desc()).all() - + time_entries = ( + TimeEntry.query.filter( + TimeEntry.project_id == self.project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True + ) + .order_by(TimeEntry.start_time.desc()) + .all() + ) + # Filter out entries already billed unbilled_entries = [] for entry in time_entries: @@ -165,18 +169,19 @@ def generate_invoice(self): for other_invoice in self.project.invoices: if other_invoice.id != invoice.id: for item in other_invoice.items: - if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(','): + if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","): already_billed = True break if already_billed: break - + if not already_billed: unbilled_entries.append(entry) - + # Group and create invoice items if unbilled_entries: from app.models.rate_override import RateOverride + grouped_entries = {} for entry in unbilled_entries: if entry.task_id: @@ -185,61 +190,60 @@ def generate_invoice(self): else: key = f"project_{entry.project_id}" description = f"Project: {entry.project.name}" - + if key not in grouped_entries: grouped_entries[key] = { - 'description': description, - 'entries': [], - 'total_hours': Decimal('0'), + "description": description, + "entries": [], + "total_hours": Decimal("0"), } - - grouped_entries[key]['entries'].append(entry) - grouped_entries[key]['total_hours'] += entry.duration_hours - + + grouped_entries[key]["entries"].append(entry) + grouped_entries[key]["total_hours"] += entry.duration_hours + # Create invoice items hourly_rate = RateOverride.resolve_rate(self.project) for group in grouped_entries.values(): - if group['total_hours'] > 0: + if group["total_hours"] > 0: item = InvoiceItem( invoice_id=invoice.id, - description=group['description'], - quantity=group['total_hours'], + description=group["description"], + quantity=group["total_hours"], unit_price=hourly_rate, - time_entry_ids=','.join(str(e.id) for e in group['entries']) + time_entry_ids=",".join(str(e.id) for e in group["entries"]), ) db.session.add(item) - + # Calculate totals invoice.calculate_totals() - + # Update recurring invoice self.last_generated_at = datetime.utcnow() self.next_run_date = self.calculate_next_run_date(issue_date) - + return invoice - + def to_dict(self): """Convert recurring invoice to dictionary""" return { - 'id': self.id, - 'name': self.name, - 'project_id': self.project_id, - 'client_id': self.client_id, - 'frequency': self.frequency, - 'interval': self.interval, - 'next_run_date': self.next_run_date.isoformat() if self.next_run_date else None, - 'end_date': self.end_date.isoformat() if self.end_date else None, - 'client_name': self.client_name, - 'client_email': self.client_email, - 'due_date_days': self.due_date_days, - 'tax_rate': float(self.tax_rate), - 'currency_code': self.currency_code, - 'auto_send': self.auto_send, - 'auto_include_time_entries': self.auto_include_time_entries, - 'is_active': self.is_active, - 'created_by': self.created_by, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'last_generated_at': self.last_generated_at.isoformat() if self.last_generated_at else None + "id": self.id, + "name": self.name, + "project_id": self.project_id, + "client_id": self.client_id, + "frequency": self.frequency, + "interval": self.interval, + "next_run_date": self.next_run_date.isoformat() if self.next_run_date else None, + "end_date": self.end_date.isoformat() if self.end_date else None, + "client_name": self.client_name, + "client_email": self.client_email, + "due_date_days": self.due_date_days, + "tax_rate": float(self.tax_rate), + "currency_code": self.currency_code, + "auto_send": self.auto_send, + "auto_include_time_entries": self.auto_include_time_entries, + "is_active": self.is_active, + "created_by": self.created_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "last_generated_at": self.last_generated_at.isoformat() if self.last_generated_at else None, } - diff --git a/app/models/reporting.py b/app/models/reporting.py index 345484eb..3a27455f 100644 --- a/app/models/reporting.py +++ b/app/models/reporting.py @@ -5,12 +5,12 @@ class SavedReportView(db.Model): """Saved configurations for the custom report builder.""" - __tablename__ = 'saved_report_views' + __tablename__ = "saved_report_views" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False) - owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - scope = db.Column(db.String(20), default='private', nullable=False) # private, team, public + owner_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + scope = db.Column(db.String(20), default="private", nullable=False) # private, team, public config_json = db.Column(db.Text, nullable=False) # JSON for filters, columns, groupings created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) @@ -22,10 +22,10 @@ def __repr__(self): class ReportEmailSchedule(db.Model): """Schedules to email saved reports on a cadence.""" - __tablename__ = 'report_email_schedules' + __tablename__ = "report_email_schedules" id = db.Column(db.Integer, primary_key=True) - saved_view_id = db.Column(db.Integer, db.ForeignKey('saved_report_views.id'), nullable=False, index=True) + saved_view_id = db.Column(db.Integer, db.ForeignKey("saved_report_views.id"), nullable=False, index=True) recipients = db.Column(db.Text, nullable=False) # comma-separated cadence = db.Column(db.String(20), nullable=False) # daily, weekly, monthly, custom-cron cron = db.Column(db.String(120), nullable=True) @@ -33,11 +33,9 @@ class ReportEmailSchedule(db.Model): next_run_at = db.Column(db.DateTime, nullable=True) last_run_at = db.Column(db.DateTime, nullable=True) active = db.Column(db.Boolean, default=True, nullable=False) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) def __repr__(self): return f"" - - diff --git a/app/models/saved_filter.py b/app/models/saved_filter.py index 8ca9a23f..d49a3eb5 100644 --- a/app/models/saved_filter.py +++ b/app/models/saved_filter.py @@ -9,12 +9,12 @@ class SavedFilter(db.Model): tags, billable, status, etc. """ - __tablename__ = 'saved_filters' + __tablename__ = "saved_filters" id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) name = db.Column(db.String(200), nullable=False) - scope = db.Column(db.String(50), nullable=False, default='global') # e.g., 'time', 'projects', 'tasks', 'reports' + scope = db.Column(db.String(50), nullable=False, default="global") # e.g., 'time', 'projects', 'tasks', 'reports' payload = db.Column(db.JSON, nullable=False, default={}) is_shared = db.Column(db.Boolean, nullable=False, default=False) @@ -22,20 +22,16 @@ class SavedFilter(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - __table_args__ = ( - db.UniqueConstraint('user_id', 'name', 'scope', name='ux_saved_filter_user_name_scope'), - ) + __table_args__ = (db.UniqueConstraint("user_id", "name", "scope", name="ux_saved_filter_user_name_scope"),) def to_dict(self): return { - 'id': self.id, - 'user_id': self.user_id, - 'name': self.name, - 'scope': self.scope, - 'payload': self.payload, - 'is_shared': self.is_shared, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "scope": self.scope, + "payload": self.payload, + "is_shared": self.is_shared, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - - diff --git a/app/models/settings.py b/app/models/settings.py index 9d4c7620..20be6935 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -3,261 +3,260 @@ from app.config import Config import os + class Settings(db.Model): """Settings model for system configuration""" - - __tablename__ = 'settings' - + + __tablename__ = "settings" + id = db.Column(db.Integer, primary_key=True) - timezone = db.Column(db.String(50), default='Europe/Rome', nullable=False) - currency = db.Column(db.String(3), default='EUR', nullable=False) + timezone = db.Column(db.String(50), default="Europe/Rome", nullable=False) + currency = db.Column(db.String(3), default="EUR", nullable=False) rounding_minutes = db.Column(db.Integer, default=1, nullable=False) single_active_timer = db.Column(db.Boolean, default=True, nullable=False) allow_self_register = db.Column(db.Boolean, default=True, nullable=False) idle_timeout_minutes = db.Column(db.Integer, default=30, nullable=False) backup_retention_days = db.Column(db.Integer, default=30, nullable=False) - backup_time = db.Column(db.String(5), default='02:00', nullable=False) # HH:MM format - export_delimiter = db.Column(db.String(1), default=',', nullable=False) - + backup_time = db.Column(db.String(5), default="02:00", nullable=False) # HH:MM format + export_delimiter = db.Column(db.String(1), default=",", nullable=False) + # Company branding for invoices - company_name = db.Column(db.String(200), default='Your Company Name', nullable=False) - company_address = db.Column(db.Text, default='Your Company Address', nullable=False) - company_email = db.Column(db.String(200), default='info@yourcompany.com', nullable=False) - company_phone = db.Column(db.String(50), default='+1 (555) 123-4567', nullable=False) - company_website = db.Column(db.String(200), default='www.yourcompany.com', nullable=False) - company_logo_filename = db.Column(db.String(255), default='', nullable=True) # Changed from company_logo_path - company_tax_id = db.Column(db.String(100), default='', nullable=True) - company_bank_info = db.Column(db.Text, default='', nullable=True) - + company_name = db.Column(db.String(200), default="Your Company Name", nullable=False) + company_address = db.Column(db.Text, default="Your Company Address", nullable=False) + company_email = db.Column(db.String(200), default="info@yourcompany.com", nullable=False) + company_phone = db.Column(db.String(50), default="+1 (555) 123-4567", nullable=False) + company_website = db.Column(db.String(200), default="www.yourcompany.com", nullable=False) + company_logo_filename = db.Column(db.String(255), default="", nullable=True) # Changed from company_logo_path + company_tax_id = db.Column(db.String(100), default="", nullable=True) + company_bank_info = db.Column(db.Text, default="", nullable=True) + # PDF template customization - invoice_pdf_template_html = db.Column(db.Text, default='', nullable=True) - invoice_pdf_template_css = db.Column(db.Text, default='', nullable=True) - invoice_pdf_design_json = db.Column(db.Text, default='', nullable=True) # Konva.js design state - + invoice_pdf_template_html = db.Column(db.Text, default="", nullable=True) + invoice_pdf_template_css = db.Column(db.Text, default="", nullable=True) + invoice_pdf_design_json = db.Column(db.Text, default="", nullable=True) # Konva.js design state + # Invoice defaults - invoice_prefix = db.Column(db.String(10), default='INV', nullable=False) + invoice_prefix = db.Column(db.String(10), default="INV", nullable=False) invoice_start_number = db.Column(db.Integer, default=1000, nullable=False) - invoice_terms = db.Column(db.Text, default='Payment is due within 30 days of invoice date.', nullable=False) - invoice_notes = db.Column(db.Text, default='Thank you for your business!', nullable=False) - + invoice_terms = db.Column(db.Text, default="Payment is due within 30 days of invoice date.", nullable=False) + invoice_notes = db.Column(db.Text, default="Thank you for your business!", nullable=False) + # Privacy and analytics settings allow_analytics = db.Column(db.Boolean, default=True, nullable=False) # Controls system info sharing for analytics - + # Kiosk mode settings kiosk_mode_enabled = db.Column(db.Boolean, default=False, nullable=False) kiosk_auto_logout_minutes = db.Column(db.Integer, default=15, nullable=False) kiosk_allow_camera_scanning = db.Column(db.Boolean, default=True, nullable=False) kiosk_require_reason_for_adjustments = db.Column(db.Boolean, default=False, nullable=False) - kiosk_default_movement_type = db.Column(db.String(20), default='adjustment', nullable=False) - + kiosk_default_movement_type = db.Column(db.String(20), default="adjustment", nullable=False) + # Email configuration settings (stored in database, takes precedence over environment variables) mail_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable database-backed email config - mail_server = db.Column(db.String(255), default='', nullable=True) + mail_server = db.Column(db.String(255), default="", nullable=True) mail_port = db.Column(db.Integer, default=587, nullable=True) mail_use_tls = db.Column(db.Boolean, default=True, nullable=True) mail_use_ssl = db.Column(db.Boolean, default=False, nullable=True) - mail_username = db.Column(db.String(255), default='', nullable=True) - mail_password = db.Column(db.String(255), default='', nullable=True) # Store encrypted in production - mail_default_sender = db.Column(db.String(255), default='', nullable=True) - + mail_username = db.Column(db.String(255), default="", nullable=True) + mail_password = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + mail_default_sender = db.Column(db.String(255), default="", nullable=True) + # Integration OAuth credentials (stored in database, takes precedence over environment variables) # Jira - jira_client_id = db.Column(db.String(255), default='', nullable=True) - jira_client_secret = db.Column(db.String(255), default='', nullable=True) # Store encrypted in production + jira_client_id = db.Column(db.String(255), default="", nullable=True) + jira_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production # Slack - slack_client_id = db.Column(db.String(255), default='', nullable=True) - slack_client_secret = db.Column(db.String(255), default='', nullable=True) # Store encrypted in production + slack_client_id = db.Column(db.String(255), default="", nullable=True) + slack_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production # GitHub - github_client_id = db.Column(db.String(255), default='', nullable=True) - github_client_secret = db.Column(db.String(255), default='', nullable=True) # Store encrypted in production - + github_client_id = db.Column(db.String(255), default="", nullable=True) + github_client_secret = db.Column(db.String(255), default="", nullable=True) # Store encrypted in production + created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + def __init__(self, **kwargs): # Set defaults from config - self.timezone = kwargs.get('timezone', Config.TZ) - self.currency = kwargs.get('currency', Config.CURRENCY) - self.rounding_minutes = kwargs.get('rounding_minutes', Config.ROUNDING_MINUTES) - self.single_active_timer = kwargs.get('single_active_timer', Config.SINGLE_ACTIVE_TIMER) - self.allow_self_register = kwargs.get('allow_self_register', Config.ALLOW_SELF_REGISTER) - self.idle_timeout_minutes = kwargs.get('idle_timeout_minutes', Config.IDLE_TIMEOUT_MINUTES) - self.backup_retention_days = kwargs.get('backup_retention_days', Config.BACKUP_RETENTION_DAYS) - self.backup_time = kwargs.get('backup_time', Config.BACKUP_TIME) - self.export_delimiter = kwargs.get('export_delimiter', ',') - + self.timezone = kwargs.get("timezone", Config.TZ) + self.currency = kwargs.get("currency", Config.CURRENCY) + self.rounding_minutes = kwargs.get("rounding_minutes", Config.ROUNDING_MINUTES) + self.single_active_timer = kwargs.get("single_active_timer", Config.SINGLE_ACTIVE_TIMER) + self.allow_self_register = kwargs.get("allow_self_register", Config.ALLOW_SELF_REGISTER) + self.idle_timeout_minutes = kwargs.get("idle_timeout_minutes", Config.IDLE_TIMEOUT_MINUTES) + self.backup_retention_days = kwargs.get("backup_retention_days", Config.BACKUP_RETENTION_DAYS) + self.backup_time = kwargs.get("backup_time", Config.BACKUP_TIME) + self.export_delimiter = kwargs.get("export_delimiter", ",") + # Set company branding defaults - self.company_name = kwargs.get('company_name', 'Your Company Name') - self.company_address = kwargs.get('company_address', 'Your Company Address') - self.company_email = kwargs.get('company_email', 'info@yourcompany.com') - self.company_phone = kwargs.get('company_phone', '+1 (555) 123-4567') - self.company_website = kwargs.get('company_website', 'www.yourcompany.com') - self.company_logo_filename = kwargs.get('company_logo_filename', '') - self.company_tax_id = kwargs.get('company_tax_id', '') - self.company_bank_info = kwargs.get('company_bank_info', '') - + self.company_name = kwargs.get("company_name", "Your Company Name") + self.company_address = kwargs.get("company_address", "Your Company Address") + self.company_email = kwargs.get("company_email", "info@yourcompany.com") + self.company_phone = kwargs.get("company_phone", "+1 (555) 123-4567") + self.company_website = kwargs.get("company_website", "www.yourcompany.com") + self.company_logo_filename = kwargs.get("company_logo_filename", "") + self.company_tax_id = kwargs.get("company_tax_id", "") + self.company_bank_info = kwargs.get("company_bank_info", "") + # PDF template customization - self.invoice_pdf_template_html = kwargs.get('invoice_pdf_template_html', '') - self.invoice_pdf_template_css = kwargs.get('invoice_pdf_template_css', '') - self.invoice_pdf_design_json = kwargs.get('invoice_pdf_design_json', '') - + self.invoice_pdf_template_html = kwargs.get("invoice_pdf_template_html", "") + self.invoice_pdf_template_css = kwargs.get("invoice_pdf_template_css", "") + self.invoice_pdf_design_json = kwargs.get("invoice_pdf_design_json", "") + # Set invoice defaults - self.invoice_prefix = kwargs.get('invoice_prefix', 'INV') - self.invoice_start_number = kwargs.get('invoice_start_number', 1000) - self.invoice_terms = kwargs.get('invoice_terms', 'Payment is due within 30 days of invoice date.') - self.invoice_notes = kwargs.get('invoice_notes', 'Thank you for your business!') - + self.invoice_prefix = kwargs.get("invoice_prefix", "INV") + self.invoice_start_number = kwargs.get("invoice_start_number", 1000) + self.invoice_terms = kwargs.get("invoice_terms", "Payment is due within 30 days of invoice date.") + self.invoice_notes = kwargs.get("invoice_notes", "Thank you for your business!") + # Kiosk mode defaults - self.kiosk_mode_enabled = kwargs.get('kiosk_mode_enabled', False) - self.kiosk_auto_logout_minutes = kwargs.get('kiosk_auto_logout_minutes', 15) - self.kiosk_allow_camera_scanning = kwargs.get('kiosk_allow_camera_scanning', True) - self.kiosk_require_reason_for_adjustments = kwargs.get('kiosk_require_reason_for_adjustments', False) - self.kiosk_default_movement_type = kwargs.get('kiosk_default_movement_type', 'adjustment') - + self.kiosk_mode_enabled = kwargs.get("kiosk_mode_enabled", False) + self.kiosk_auto_logout_minutes = kwargs.get("kiosk_auto_logout_minutes", 15) + self.kiosk_allow_camera_scanning = kwargs.get("kiosk_allow_camera_scanning", True) + self.kiosk_require_reason_for_adjustments = kwargs.get("kiosk_require_reason_for_adjustments", False) + self.kiosk_default_movement_type = kwargs.get("kiosk_default_movement_type", "adjustment") + # Email configuration defaults - self.mail_enabled = kwargs.get('mail_enabled', False) - self.mail_server = kwargs.get('mail_server', '') - self.mail_port = kwargs.get('mail_port', 587) - self.mail_use_tls = kwargs.get('mail_use_tls', True) - self.mail_use_ssl = kwargs.get('mail_use_ssl', False) - self.mail_username = kwargs.get('mail_username', '') - self.mail_password = kwargs.get('mail_password', '') - self.mail_default_sender = kwargs.get('mail_default_sender', '') - + self.mail_enabled = kwargs.get("mail_enabled", False) + self.mail_server = kwargs.get("mail_server", "") + self.mail_port = kwargs.get("mail_port", 587) + self.mail_use_tls = kwargs.get("mail_use_tls", True) + self.mail_use_ssl = kwargs.get("mail_use_ssl", False) + self.mail_username = kwargs.get("mail_username", "") + self.mail_password = kwargs.get("mail_password", "") + self.mail_default_sender = kwargs.get("mail_default_sender", "") + # Integration OAuth credentials defaults - self.jira_client_id = kwargs.get('jira_client_id', '') - self.jira_client_secret = kwargs.get('jira_client_secret', '') - self.slack_client_id = kwargs.get('slack_client_id', '') - self.slack_client_secret = kwargs.get('slack_client_secret', '') - self.github_client_id = kwargs.get('github_client_id', '') - self.github_client_secret = kwargs.get('github_client_secret', '') - + self.jira_client_id = kwargs.get("jira_client_id", "") + self.jira_client_secret = kwargs.get("jira_client_secret", "") + self.slack_client_id = kwargs.get("slack_client_id", "") + self.slack_client_secret = kwargs.get("slack_client_secret", "") + self.github_client_id = kwargs.get("github_client_id", "") + self.github_client_secret = kwargs.get("github_client_secret", "") + def __repr__(self): - return f'' - + return f"" + def get_logo_url(self): """Get the full URL for the company logo""" if self.company_logo_filename: - return f'/uploads/logos/{self.company_logo_filename}' + return f"/uploads/logos/{self.company_logo_filename}" return None - + def get_logo_path(self): """Get the full file system path for the company logo""" if not self.company_logo_filename: return None - + try: from flask import current_app - upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'logos') + + upload_folder = os.path.join(current_app.root_path, "static", "uploads", "logos") return os.path.join(upload_folder, self.company_logo_filename) except RuntimeError: # current_app not available (e.g., during testing or initialization) # Fallback to a relative path - return os.path.join('app', 'static', 'uploads', 'logos', self.company_logo_filename) - + return os.path.join("app", "static", "uploads", "logos", self.company_logo_filename) + def has_logo(self): """Check if company has a logo uploaded""" if not self.company_logo_filename: return False - + logo_path = self.get_logo_path() return logo_path and os.path.exists(logo_path) - + def get_mail_config(self): """Get email configuration, preferring database settings over environment variables""" if self.mail_enabled and self.mail_server: return { - 'MAIL_SERVER': self.mail_server, - 'MAIL_PORT': self.mail_port or 587, - 'MAIL_USE_TLS': self.mail_use_tls if self.mail_use_tls is not None else True, - 'MAIL_USE_SSL': self.mail_use_ssl if self.mail_use_ssl is not None else False, - 'MAIL_USERNAME': self.mail_username or None, - 'MAIL_PASSWORD': self.mail_password or None, - 'MAIL_DEFAULT_SENDER': self.mail_default_sender or 'noreply@timetracker.local', + "MAIL_SERVER": self.mail_server, + "MAIL_PORT": self.mail_port or 587, + "MAIL_USE_TLS": self.mail_use_tls if self.mail_use_tls is not None else True, + "MAIL_USE_SSL": self.mail_use_ssl if self.mail_use_ssl is not None else False, + "MAIL_USERNAME": self.mail_username or None, + "MAIL_PASSWORD": self.mail_password or None, + "MAIL_DEFAULT_SENDER": self.mail_default_sender or "noreply@timetracker.local", } return None - + def get_integration_credentials(self, provider: str) -> dict: """Get integration OAuth credentials, preferring database settings over environment variables. - + Args: provider: One of 'jira', 'slack', or 'github' - + Returns: dict with 'client_id' and 'client_secret' keys, or empty dict if not configured """ import os - - if provider == 'jira': - client_id = self.jira_client_id or os.getenv('JIRA_CLIENT_ID', '') - client_secret = self.jira_client_secret or os.getenv('JIRA_CLIENT_SECRET', '') - elif provider == 'slack': - client_id = self.slack_client_id or os.getenv('SLACK_CLIENT_ID', '') - client_secret = self.slack_client_secret or os.getenv('SLACK_CLIENT_SECRET', '') - elif provider == 'github': - client_id = self.github_client_id or os.getenv('GITHUB_CLIENT_ID', '') - client_secret = self.github_client_secret or os.getenv('GITHUB_CLIENT_SECRET', '') + + if provider == "jira": + client_id = self.jira_client_id or os.getenv("JIRA_CLIENT_ID", "") + client_secret = self.jira_client_secret or os.getenv("JIRA_CLIENT_SECRET", "") + elif provider == "slack": + client_id = self.slack_client_id or os.getenv("SLACK_CLIENT_ID", "") + client_secret = self.slack_client_secret or os.getenv("SLACK_CLIENT_SECRET", "") + elif provider == "github": + client_id = self.github_client_id or os.getenv("GITHUB_CLIENT_ID", "") + client_secret = self.github_client_secret or os.getenv("GITHUB_CLIENT_SECRET", "") else: return {} - - return { - 'client_id': client_id, - 'client_secret': client_secret - } - + + return {"client_id": client_id, "client_secret": client_secret} + def to_dict(self): """Convert settings to dictionary for API responses""" return { - 'id': self.id, - 'timezone': self.timezone, - 'currency': self.currency, - 'rounding_minutes': self.rounding_minutes, - 'single_active_timer': self.single_active_timer, - 'allow_self_register': self.allow_self_register, - 'idle_timeout_minutes': self.idle_timeout_minutes, - 'backup_retention_days': self.backup_retention_days, - 'backup_time': self.backup_time, - 'export_delimiter': self.export_delimiter, - 'company_name': self.company_name, - 'company_address': self.company_address, - 'company_email': self.company_email, - 'company_phone': self.company_phone, - 'company_website': self.company_website, - 'company_logo_filename': self.company_logo_filename, - 'company_logo_url': self.get_logo_url(), - 'has_logo': self.has_logo(), - 'company_tax_id': self.company_tax_id, - 'company_bank_info': self.company_bank_info, - 'invoice_prefix': self.invoice_prefix, - 'invoice_start_number': self.invoice_start_number, - 'invoice_terms': self.invoice_terms, - 'invoice_notes': self.invoice_notes, - 'invoice_pdf_template_html': self.invoice_pdf_template_html, - 'invoice_pdf_template_css': self.invoice_pdf_template_css, - 'invoice_pdf_design_json': self.invoice_pdf_design_json, - 'allow_analytics': self.allow_analytics, - 'mail_enabled': self.mail_enabled, - 'mail_server': self.mail_server, - 'mail_port': self.mail_port, - 'mail_use_tls': self.mail_use_tls, - 'mail_use_ssl': self.mail_use_ssl, - 'mail_username': self.mail_username, - 'mail_password_set': bool(self.mail_password), # Don't expose actual password - 'mail_default_sender': self.mail_default_sender, - 'jira_client_id': self.jira_client_id or '', - 'jira_client_secret_set': bool(self.jira_client_secret), # Don't expose actual secret - 'slack_client_id': self.slack_client_id or '', - 'slack_client_secret_set': bool(self.slack_client_secret), # Don't expose actual secret - 'github_client_id': self.github_client_id or '', - 'github_client_secret_set': bool(self.github_client_secret), # Don't expose actual secret - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "timezone": self.timezone, + "currency": self.currency, + "rounding_minutes": self.rounding_minutes, + "single_active_timer": self.single_active_timer, + "allow_self_register": self.allow_self_register, + "idle_timeout_minutes": self.idle_timeout_minutes, + "backup_retention_days": self.backup_retention_days, + "backup_time": self.backup_time, + "export_delimiter": self.export_delimiter, + "company_name": self.company_name, + "company_address": self.company_address, + "company_email": self.company_email, + "company_phone": self.company_phone, + "company_website": self.company_website, + "company_logo_filename": self.company_logo_filename, + "company_logo_url": self.get_logo_url(), + "has_logo": self.has_logo(), + "company_tax_id": self.company_tax_id, + "company_bank_info": self.company_bank_info, + "invoice_prefix": self.invoice_prefix, + "invoice_start_number": self.invoice_start_number, + "invoice_terms": self.invoice_terms, + "invoice_notes": self.invoice_notes, + "invoice_pdf_template_html": self.invoice_pdf_template_html, + "invoice_pdf_template_css": self.invoice_pdf_template_css, + "invoice_pdf_design_json": self.invoice_pdf_design_json, + "allow_analytics": self.allow_analytics, + "mail_enabled": self.mail_enabled, + "mail_server": self.mail_server, + "mail_port": self.mail_port, + "mail_use_tls": self.mail_use_tls, + "mail_use_ssl": self.mail_use_ssl, + "mail_username": self.mail_username, + "mail_password_set": bool(self.mail_password), # Don't expose actual password + "mail_default_sender": self.mail_default_sender, + "jira_client_id": self.jira_client_id or "", + "jira_client_secret_set": bool(self.jira_client_secret), # Don't expose actual secret + "slack_client_id": self.slack_client_id or "", + "slack_client_secret_set": bool(self.slack_client_secret), # Don't expose actual secret + "github_client_id": self.github_client_id or "", + "github_client_secret_set": bool(self.github_client_secret), # Don't expose actual secret + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @classmethod def get_settings(cls): """Get the singleton settings instance, creating it if it doesn't exist. - + When creating a new Settings instance, it will be initialized from environment variables (.env file) as initial values. """ @@ -269,6 +268,7 @@ def get_settings(cls): # Handle case where columns don't exist yet (migration not run) # Log but don't fail - return fallback instance import logging + logger = logging.getLogger(__name__) logger.warning(f"Could not query settings (migration may not be run): {e}") # Rollback the failed transaction @@ -278,7 +278,7 @@ def get_settings(cls): pass # Return fallback instance with defaults return cls() - + # Avoid performing session writes during flush/commit phases. # When called from default column factories (e.g., created_at=local_now), # SQLAlchemy may be in the middle of a flush. Writing here would raise @@ -302,55 +302,55 @@ def get_settings(cls): except Exception: # Ignore rollback failures to avoid masking original contexts pass - + # Fallback: return a non-persisted Settings instance return cls() - + @classmethod def update_settings(cls, **kwargs): """Update settings with new values""" settings = cls.get_settings() - + for key, value in kwargs.items(): if hasattr(settings, key): setattr(settings, key, value) - + settings.updated_at = datetime.utcnow() db.session.commit() return settings - + @classmethod def _initialize_from_env(cls, settings_instance): """ Initialize Settings instance from environment variables (.env file). This is called when creating a new Settings instance to use .env values as initial startup values. - + Args: settings_instance: Settings instance to initialize """ # Map environment variable names to Settings model attributes env_mapping = { - 'TZ': 'timezone', - 'CURRENCY': 'currency', - 'ROUNDING_MINUTES': 'rounding_minutes', - 'SINGLE_ACTIVE_TIMER': 'single_active_timer', - 'ALLOW_SELF_REGISTER': 'allow_self_register', - 'IDLE_TIMEOUT_MINUTES': 'idle_timeout_minutes', - 'BACKUP_RETENTION_DAYS': 'backup_retention_days', - 'BACKUP_TIME': 'backup_time', + "TZ": "timezone", + "CURRENCY": "currency", + "ROUNDING_MINUTES": "rounding_minutes", + "SINGLE_ACTIVE_TIMER": "single_active_timer", + "ALLOW_SELF_REGISTER": "allow_self_register", + "IDLE_TIMEOUT_MINUTES": "idle_timeout_minutes", + "BACKUP_RETENTION_DAYS": "backup_retention_days", + "BACKUP_TIME": "backup_time", } - + for env_var, attr_name in env_mapping.items(): if hasattr(settings_instance, attr_name): env_value = os.getenv(env_var) if env_value is not None: # Convert value types based on attribute type current_value = getattr(settings_instance, attr_name) - + if isinstance(current_value, bool): # Handle boolean values - setattr(settings_instance, attr_name, env_value.lower() == 'true') + setattr(settings_instance, attr_name, env_value.lower() == "true") elif isinstance(current_value, int): # Handle integer values try: @@ -360,32 +360,33 @@ def _initialize_from_env(cls, settings_instance): else: # Handle string values setattr(settings_instance, attr_name, env_value) - + @classmethod def sync_from_env(cls): """ Sync Settings from environment variables (.env file) for fields that haven't been customized in the WebUI. This is useful for initializing Settings on startup or when new environment variables are added. - + Only updates fields that are still at their default values (not customized via WebUI). """ try: settings = cls.get_settings() - if not settings or not hasattr(settings, 'id'): + if not settings or not hasattr(settings, "id"): # Settings doesn't exist in DB yet, get_settings will create it return - + # Only sync if Settings was just created (id is None means it's a new instance) # For existing Settings, we don't overwrite WebUI changes # This method is mainly for ensuring new Settings get initialized from .env if settings.id is None: cls._initialize_from_env(settings) - if hasattr(db.session, 'add'): + if hasattr(db.session, "add"): db.session.add(settings) db.session.commit() except Exception as e: import logging + logger = logging.getLogger(__name__) logger.warning(f"Could not sync Settings from environment: {e}") try: diff --git a/app/models/stock_item.py b/app/models/stock_item.py index 758ccd65..19a043e9 100644 --- a/app/models/stock_item.py +++ b/app/models/stock_item.py @@ -1,4 +1,5 @@ """StockItem model for inventory management""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,18 +7,18 @@ class StockItem(db.Model): """StockItem model - represents a product/item in the inventory catalog""" - - __tablename__ = 'stock_items' - + + __tablename__ = "stock_items" + id = db.Column(db.Integer, primary_key=True) sku = db.Column(db.String(100), unique=True, nullable=False, index=True) name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) category = db.Column(db.String(100), nullable=True, index=True) - unit = db.Column(db.String(20), nullable=False, default='pcs') + unit = db.Column(db.String(20), nullable=False, default="pcs") default_cost = db.Column(db.Numeric(10, 2), nullable=True) default_price = db.Column(db.Numeric(10, 2), nullable=True) - currency_code = db.Column(db.String(3), nullable=False, default='EUR') + currency_code = db.Column(db.String(3), nullable=False, default="EUR") barcode = db.Column(db.String(100), nullable=True, index=True) is_active = db.Column(db.Boolean, default=True, nullable=False) is_trackable = db.Column(db.Boolean, default=True, nullable=False) @@ -29,24 +30,45 @@ class StockItem(db.Model): notes = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Relationships - warehouse_stock = db.relationship('WarehouseStock', backref='stock_item', lazy='dynamic', cascade='all, delete-orphan') - stock_movements = db.relationship('StockMovement', backref='stock_item', lazy='dynamic') - reservations = db.relationship('StockReservation', backref='stock_item', lazy='dynamic') - supplier_items = db.relationship('SupplierStockItem', backref='stock_item', lazy='dynamic', cascade='all, delete-orphan') - - def __init__(self, sku, name, created_by, description=None, category=None, unit='pcs', - default_cost=None, default_price=None, currency_code='EUR', barcode=None, - is_active=True, is_trackable=True, reorder_point=None, reorder_quantity=None, - supplier=None, supplier_sku=None, image_url=None, notes=None): + warehouse_stock = db.relationship( + "WarehouseStock", backref="stock_item", lazy="dynamic", cascade="all, delete-orphan" + ) + stock_movements = db.relationship("StockMovement", backref="stock_item", lazy="dynamic") + reservations = db.relationship("StockReservation", backref="stock_item", lazy="dynamic") + supplier_items = db.relationship( + "SupplierStockItem", backref="stock_item", lazy="dynamic", cascade="all, delete-orphan" + ) + + def __init__( + self, + sku, + name, + created_by, + description=None, + category=None, + unit="pcs", + default_cost=None, + default_price=None, + currency_code="EUR", + barcode=None, + is_active=True, + is_trackable=True, + reorder_point=None, + reorder_quantity=None, + supplier=None, + supplier_sku=None, + image_url=None, + notes=None, + ): self.sku = sku.strip().upper() self.name = name.strip() self.created_by = created_by self.description = description.strip() if description else None self.category = category.strip() if category else None - self.unit = unit.strip() if unit else 'pcs' + self.unit = unit.strip() if unit else "pcs" self.default_cost = Decimal(str(default_cost)) if default_cost else None self.default_price = Decimal(str(default_price)) if default_price else None self.currency_code = currency_code.upper() @@ -59,104 +81,99 @@ def __init__(self, sku, name, created_by, description=None, category=None, unit= self.supplier_sku = supplier_sku.strip() if supplier_sku else None self.image_url = image_url.strip() if image_url else None self.notes = notes.strip() if notes else None - + def __repr__(self): - return f'' - + return f"" + @property def total_quantity_on_hand(self): """Calculate total quantity across all warehouses""" if not self.is_trackable: return None from .warehouse_stock import WarehouseStock - total = db.session.query(db.func.sum(WarehouseStock.quantity_on_hand)).filter_by( - stock_item_id=self.id - ).scalar() - return Decimal(str(total)) if total else Decimal('0') - + + total = db.session.query(db.func.sum(WarehouseStock.quantity_on_hand)).filter_by(stock_item_id=self.id).scalar() + return Decimal(str(total)) if total else Decimal("0") + @property def total_quantity_reserved(self): """Calculate total reserved quantity across all warehouses""" if not self.is_trackable: return None from .warehouse_stock import WarehouseStock - total = db.session.query(db.func.sum(WarehouseStock.quantity_reserved)).filter_by( - stock_item_id=self.id - ).scalar() - return Decimal(str(total)) if total else Decimal('0') - + + total = ( + db.session.query(db.func.sum(WarehouseStock.quantity_reserved)).filter_by(stock_item_id=self.id).scalar() + ) + return Decimal(str(total)) if total else Decimal("0") + @property def total_quantity_available(self): """Calculate total available quantity (on-hand minus reserved)""" if not self.is_trackable: return None - on_hand = self.total_quantity_on_hand or Decimal('0') - reserved = self.total_quantity_reserved or Decimal('0') + on_hand = self.total_quantity_on_hand or Decimal("0") + reserved = self.total_quantity_reserved or Decimal("0") return on_hand - reserved - + @property def is_low_stock(self): """Check if any warehouse is below reorder point""" if not self.is_trackable or not self.reorder_point: return False from .warehouse_stock import WarehouseStock + low_stock = WarehouseStock.query.filter( - WarehouseStock.stock_item_id == self.id, - WarehouseStock.quantity_on_hand < self.reorder_point + WarehouseStock.stock_item_id == self.id, WarehouseStock.quantity_on_hand < self.reorder_point ).first() return low_stock is not None - + def get_stock_level(self, warehouse_id): """Get stock level for a specific warehouse""" if not self.is_trackable: return None from .warehouse_stock import WarehouseStock - stock = WarehouseStock.query.filter_by( - stock_item_id=self.id, - warehouse_id=warehouse_id - ).first() - return stock.quantity_on_hand if stock else Decimal('0') - + + stock = WarehouseStock.query.filter_by(stock_item_id=self.id, warehouse_id=warehouse_id).first() + return stock.quantity_on_hand if stock else Decimal("0") + def get_available_quantity(self, warehouse_id): """Get available quantity for a specific warehouse""" if not self.is_trackable: return None from .warehouse_stock import WarehouseStock - stock = WarehouseStock.query.filter_by( - stock_item_id=self.id, - warehouse_id=warehouse_id - ).first() + + stock = WarehouseStock.query.filter_by(stock_item_id=self.id, warehouse_id=warehouse_id).first() if not stock: - return Decimal('0') + return Decimal("0") return stock.quantity_on_hand - stock.quantity_reserved - + def to_dict(self): """Convert stock item to dictionary""" return { - 'id': self.id, - 'sku': self.sku, - 'name': self.name, - 'description': self.description, - 'category': self.category, - 'unit': self.unit, - 'default_cost': float(self.default_cost) if self.default_cost else None, - 'default_price': float(self.default_price) if self.default_price else None, - 'currency_code': self.currency_code, - 'barcode': self.barcode, - 'is_active': self.is_active, - 'is_trackable': self.is_trackable, - 'reorder_point': float(self.reorder_point) if self.reorder_point else None, - 'reorder_quantity': float(self.reorder_quantity) if self.reorder_quantity else None, - 'supplier': self.supplier, - 'supplier_sku': self.supplier_sku, - 'image_url': self.image_url, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'created_by': self.created_by, - 'total_quantity_on_hand': float(self.total_quantity_on_hand) if self.total_quantity_on_hand else None, - 'total_quantity_reserved': float(self.total_quantity_reserved) if self.total_quantity_reserved else None, - 'total_quantity_available': float(self.total_quantity_available) if self.total_quantity_available else None, - 'is_low_stock': self.is_low_stock + "id": self.id, + "sku": self.sku, + "name": self.name, + "description": self.description, + "category": self.category, + "unit": self.unit, + "default_cost": float(self.default_cost) if self.default_cost else None, + "default_price": float(self.default_price) if self.default_price else None, + "currency_code": self.currency_code, + "barcode": self.barcode, + "is_active": self.is_active, + "is_trackable": self.is_trackable, + "reorder_point": float(self.reorder_point) if self.reorder_point else None, + "reorder_quantity": float(self.reorder_quantity) if self.reorder_quantity else None, + "supplier": self.supplier, + "supplier_sku": self.supplier_sku, + "image_url": self.image_url, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "created_by": self.created_by, + "total_quantity_on_hand": float(self.total_quantity_on_hand) if self.total_quantity_on_hand else None, + "total_quantity_reserved": float(self.total_quantity_reserved) if self.total_quantity_reserved else None, + "total_quantity_available": float(self.total_quantity_available) if self.total_quantity_available else None, + "is_low_stock": self.is_low_stock, } - diff --git a/app/models/stock_movement.py b/app/models/stock_movement.py index 46bd880b..bc3ad626 100644 --- a/app/models/stock_movement.py +++ b/app/models/stock_movement.py @@ -1,4 +1,5 @@ """StockMovement model for tracking inventory movements""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,33 +7,48 @@ class StockMovement(db.Model): """StockMovement model - tracks all inventory movements""" - - __tablename__ = 'stock_movements' - + + __tablename__ = "stock_movements" + id = db.Column(db.Integer, primary_key=True) - movement_type = db.Column(db.String(20), nullable=False, index=True) # 'adjustment', 'transfer', 'sale', 'purchase', 'return', 'waste' - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=False, index=True) - warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=False, index=True) + movement_type = db.Column( + db.String(20), nullable=False, index=True + ) # 'adjustment', 'transfer', 'sale', 'purchase', 'return', 'waste' + stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=False, index=True) + warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=False, index=True) quantity = db.Column(db.Numeric(10, 2), nullable=False) # Positive for additions, negative for removals - reference_type = db.Column(db.String(50), nullable=True, index=True) # 'invoice', 'quote', 'project', 'manual', 'purchase_order' + reference_type = db.Column( + db.String(50), nullable=True, index=True + ) # 'invoice', 'quote', 'project', 'manual', 'purchase_order' reference_id = db.Column(db.Integer, nullable=True, index=True) unit_cost = db.Column(db.Numeric(10, 2), nullable=True) reason = db.Column(db.String(500), nullable=True) notes = db.Column(db.Text, nullable=True) - moved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + moved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) moved_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) - + # Relationships - moved_by_user = db.relationship('User', foreign_keys=[moved_by]) - + moved_by_user = db.relationship("User", foreign_keys=[moved_by]) + # Composite index for reference lookups __table_args__ = ( - db.Index('ix_stock_movements_reference', 'reference_type', 'reference_id'), - db.Index('ix_stock_movements_item_date', 'stock_item_id', 'moved_at'), + db.Index("ix_stock_movements_reference", "reference_type", "reference_id"), + db.Index("ix_stock_movements_item_date", "stock_item_id", "moved_at"), ) - - def __init__(self, movement_type, stock_item_id, warehouse_id, quantity, moved_by, - reference_type=None, reference_id=None, unit_cost=None, reason=None, notes=None): + + def __init__( + self, + movement_type, + stock_item_id, + warehouse_id, + quantity, + moved_by, + reference_type=None, + reference_id=None, + unit_cost=None, + reason=None, + notes=None, + ): self.movement_type = movement_type self.stock_item_id = stock_item_id self.warehouse_id = warehouse_id @@ -43,39 +59,50 @@ def __init__(self, movement_type, stock_item_id, warehouse_id, quantity, moved_b self.unit_cost = Decimal(str(unit_cost)) if unit_cost else None self.reason = reason.strip() if reason else None self.notes = notes.strip() if notes else None - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert stock movement to dictionary""" return { - 'id': self.id, - 'movement_type': self.movement_type, - 'stock_item_id': self.stock_item_id, - 'warehouse_id': self.warehouse_id, - 'quantity': float(self.quantity), - 'reference_type': self.reference_type, - 'reference_id': self.reference_id, - 'unit_cost': float(self.unit_cost) if self.unit_cost else None, - 'reason': self.reason, - 'notes': self.notes, - 'moved_by': self.moved_by, - 'moved_at': self.moved_at.isoformat() if self.moved_at else None + "id": self.id, + "movement_type": self.movement_type, + "stock_item_id": self.stock_item_id, + "warehouse_id": self.warehouse_id, + "quantity": float(self.quantity), + "reference_type": self.reference_type, + "reference_id": self.reference_id, + "unit_cost": float(self.unit_cost) if self.unit_cost else None, + "reason": self.reason, + "notes": self.notes, + "moved_by": self.moved_by, + "moved_at": self.moved_at.isoformat() if self.moved_at else None, } - + @classmethod - def record_movement(cls, movement_type, stock_item_id, warehouse_id, quantity, moved_by, - reference_type=None, reference_id=None, unit_cost=None, reason=None, notes=None, - update_stock=True): + def record_movement( + cls, + movement_type, + stock_item_id, + warehouse_id, + quantity, + moved_by, + reference_type=None, + reference_id=None, + unit_cost=None, + reason=None, + notes=None, + update_stock=True, + ): """ Record a stock movement and optionally update warehouse stock levels - + Returns: tuple: (StockMovement instance, updated WarehouseStock instance or None) """ from .warehouse_stock import WarehouseStock - + movement = cls( movement_type=movement_type, stock_item_id=stock_item_id, @@ -86,30 +113,22 @@ def record_movement(cls, movement_type, stock_item_id, warehouse_id, quantity, m reference_id=reference_id, unit_cost=unit_cost, reason=reason, - notes=notes + notes=notes, ) - + db.session.add(movement) - + updated_stock = None if update_stock: # Get or create warehouse stock record - stock = WarehouseStock.query.filter_by( - warehouse_id=warehouse_id, - stock_item_id=stock_item_id - ).first() - + stock = WarehouseStock.query.filter_by(warehouse_id=warehouse_id, stock_item_id=stock_item_id).first() + if not stock: - stock = WarehouseStock( - warehouse_id=warehouse_id, - stock_item_id=stock_item_id, - quantity_on_hand=0 - ) + stock = WarehouseStock(warehouse_id=warehouse_id, stock_item_id=stock_item_id, quantity_on_hand=0) db.session.add(stock) - + # Update stock level stock.adjust_on_hand(quantity) updated_stock = stock - - return movement, updated_stock + return movement, updated_stock diff --git a/app/models/stock_reservation.py b/app/models/stock_reservation.py index aeed855d..8a051a6f 100644 --- a/app/models/stock_reservation.py +++ b/app/models/stock_reservation.py @@ -1,4 +1,5 @@ """StockReservation model for reserving stock""" + from datetime import datetime, timedelta from decimal import Decimal from app import db @@ -6,33 +7,42 @@ class StockReservation(db.Model): """StockReservation model - reserves stock for quotes/invoices/projects""" - - __tablename__ = 'stock_reservations' - + + __tablename__ = "stock_reservations" + id = db.Column(db.Integer, primary_key=True) - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=False, index=True) - warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id'), nullable=False, index=True) + stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=False, index=True) + warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=False, index=True) quantity = db.Column(db.Numeric(10, 2), nullable=False) reservation_type = db.Column(db.String(20), nullable=False, index=True) # 'quote', 'invoice', 'project' reservation_id = db.Column(db.Integer, nullable=False, index=True) - status = db.Column(db.String(20), nullable=False, default='reserved') # 'reserved', 'fulfilled', 'cancelled', 'expired' + status = db.Column( + db.String(20), nullable=False, default="reserved" + ) # 'reserved', 'fulfilled', 'cancelled', 'expired' expires_at = db.Column(db.DateTime, nullable=True, index=True) - reserved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + reserved_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) reserved_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) fulfilled_at = db.Column(db.DateTime, nullable=True) cancelled_at = db.Column(db.DateTime, nullable=True) notes = db.Column(db.Text, nullable=True) - + # Relationships - reserved_by_user = db.relationship('User', foreign_keys=[reserved_by]) - + reserved_by_user = db.relationship("User", foreign_keys=[reserved_by]) + # Composite index for reservation lookups - __table_args__ = ( - db.Index('ix_stock_reservations_reservation', 'reservation_type', 'reservation_id'), - ) - - def __init__(self, stock_item_id, warehouse_id, quantity, reservation_type, reservation_id, - reserved_by, expires_at=None, notes=None): + __table_args__ = (db.Index("ix_stock_reservations_reservation", "reservation_type", "reservation_id"),) + + def __init__( + self, + stock_item_id, + warehouse_id, + quantity, + reservation_type, + reservation_id, + reserved_by, + expires_at=None, + notes=None, + ): self.stock_item_id = stock_item_id self.warehouse_id = warehouse_id self.quantity = Decimal(str(quantity)) @@ -41,102 +51,98 @@ def __init__(self, stock_item_id, warehouse_id, quantity, reservation_type, rese self.reserved_by = reserved_by self.expires_at = expires_at self.notes = notes.strip() if notes else None - self.status = 'reserved' - + self.status = "reserved" + def __repr__(self): - return f'' - + return f"" + @property def is_expired(self): """Check if reservation has expired""" if not self.expires_at: return False - return datetime.utcnow() > self.expires_at and self.status == 'reserved' - + return datetime.utcnow() > self.expires_at and self.status == "reserved" + def fulfill(self): """Mark reservation as fulfilled""" - if self.status != 'reserved': + if self.status != "reserved": raise ValueError(f"Cannot fulfill reservation with status: {self.status}") - self.status = 'fulfilled' + self.status = "fulfilled" self.fulfilled_at = datetime.utcnow() - + # Release reserved quantity from warehouse stock from .warehouse_stock import WarehouseStock - stock = WarehouseStock.query.filter_by( - warehouse_id=self.warehouse_id, - stock_item_id=self.stock_item_id - ).first() + + stock = WarehouseStock.query.filter_by(warehouse_id=self.warehouse_id, stock_item_id=self.stock_item_id).first() if stock: stock.release_reservation(self.quantity) - + def cancel(self): """Cancel the reservation""" - if self.status not in ('reserved', 'expired'): + if self.status not in ("reserved", "expired"): raise ValueError(f"Cannot cancel reservation with status: {self.status}") - + # Release reserved quantity from warehouse stock from .warehouse_stock import WarehouseStock - stock = WarehouseStock.query.filter_by( - warehouse_id=self.warehouse_id, - stock_item_id=self.stock_item_id - ).first() + + stock = WarehouseStock.query.filter_by(warehouse_id=self.warehouse_id, stock_item_id=self.stock_item_id).first() if stock: stock.release_reservation(self.quantity) - - self.status = 'cancelled' + + self.status = "cancelled" self.cancelled_at = datetime.utcnow() - + def expire(self): """Mark reservation as expired""" - if self.status != 'reserved': + if self.status != "reserved": return - + # Release reserved quantity from warehouse stock from .warehouse_stock import WarehouseStock - stock = WarehouseStock.query.filter_by( - warehouse_id=self.warehouse_id, - stock_item_id=self.stock_item_id - ).first() + + stock = WarehouseStock.query.filter_by(warehouse_id=self.warehouse_id, stock_item_id=self.stock_item_id).first() if stock: stock.release_reservation(self.quantity) - - self.status = 'expired' - + + self.status = "expired" + @classmethod - def create_reservation(cls, stock_item_id, warehouse_id, quantity, reservation_type, - reservation_id, reserved_by, expires_in_days=30, notes=None): + def create_reservation( + cls, + stock_item_id, + warehouse_id, + quantity, + reservation_type, + reservation_id, + reserved_by, + expires_in_days=30, + notes=None, + ): """ Create a stock reservation and update warehouse stock - + Returns: tuple: (StockReservation instance, updated WarehouseStock instance) """ from .warehouse_stock import WarehouseStock - + # Calculate expiration date expires_at = None if expires_in_days: expires_at = datetime.utcnow() + timedelta(days=expires_in_days) - + # Get or create warehouse stock record - stock = WarehouseStock.query.filter_by( - warehouse_id=warehouse_id, - stock_item_id=stock_item_id - ).first() - + stock = WarehouseStock.query.filter_by(warehouse_id=warehouse_id, stock_item_id=stock_item_id).first() + if not stock: - stock = WarehouseStock( - warehouse_id=warehouse_id, - stock_item_id=stock_item_id, - quantity_on_hand=0 - ) + stock = WarehouseStock(warehouse_id=warehouse_id, stock_item_id=stock_item_id, quantity_on_hand=0) db.session.add(stock) - + # Check available quantity available = stock.quantity_available if Decimal(str(quantity)) > available: raise ValueError(f"Insufficient stock. Available: {available}, Requested: {quantity}") - + # Create reservation reservation = cls( stock_item_id=stock_item_id, @@ -146,32 +152,31 @@ def create_reservation(cls, stock_item_id, warehouse_id, quantity, reservation_t reservation_id=reservation_id, reserved_by=reserved_by, expires_at=expires_at, - notes=notes + notes=notes, ) - + # Reserve quantity in warehouse stock stock.reserve(quantity) - + db.session.add(reservation) - + return reservation, stock - + def to_dict(self): """Convert stock reservation to dictionary""" return { - 'id': self.id, - 'stock_item_id': self.stock_item_id, - 'warehouse_id': self.warehouse_id, - 'quantity': float(self.quantity), - 'reservation_type': self.reservation_type, - 'reservation_id': self.reservation_id, - 'status': self.status, - 'expires_at': self.expires_at.isoformat() if self.expires_at else None, - 'reserved_by': self.reserved_by, - 'reserved_at': self.reserved_at.isoformat() if self.reserved_at else None, - 'fulfilled_at': self.fulfilled_at.isoformat() if self.fulfilled_at else None, - 'cancelled_at': self.cancelled_at.isoformat() if self.cancelled_at else None, - 'notes': self.notes, - 'is_expired': self.is_expired + "id": self.id, + "stock_item_id": self.stock_item_id, + "warehouse_id": self.warehouse_id, + "quantity": float(self.quantity), + "reservation_type": self.reservation_type, + "reservation_id": self.reservation_id, + "status": self.status, + "expires_at": self.expires_at.isoformat() if self.expires_at else None, + "reserved_by": self.reserved_by, + "reserved_at": self.reserved_at.isoformat() if self.reserved_at else None, + "fulfilled_at": self.fulfilled_at.isoformat() if self.fulfilled_at else None, + "cancelled_at": self.cancelled_at.isoformat() if self.cancelled_at else None, + "notes": self.notes, + "is_expired": self.is_expired, } - diff --git a/app/models/supplier.py b/app/models/supplier.py index fe43f044..41ff336c 100644 --- a/app/models/supplier.py +++ b/app/models/supplier.py @@ -1,13 +1,14 @@ """Supplier model for inventory management""" + from datetime import datetime from app import db class Supplier(db.Model): """Supplier model - represents a supplier/vendor""" - - __tablename__ = 'suppliers' - + + __tablename__ = "suppliers" + id = db.Column(db.Integer, primary_key=True) code = db.Column(db.String(50), unique=True, nullable=False, index=True) name = db.Column(db.String(200), nullable=False) @@ -19,19 +20,35 @@ class Supplier(db.Model): website = db.Column(db.String(500), nullable=True) tax_id = db.Column(db.String(100), nullable=True) payment_terms = db.Column(db.String(100), nullable=True) # e.g., "Net 30", "Net 60" - currency_code = db.Column(db.String(3), nullable=False, default='EUR') + currency_code = db.Column(db.String(3), nullable=False, default="EUR") is_active = db.Column(db.Boolean, default=True, nullable=False) notes = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Relationships - supplier_items = db.relationship('SupplierStockItem', backref='supplier', lazy='dynamic', cascade='all, delete-orphan') - - def __init__(self, code, name, created_by, description=None, contact_person=None, - email=None, phone=None, address=None, website=None, tax_id=None, - payment_terms=None, currency_code='EUR', is_active=True, notes=None): + supplier_items = db.relationship( + "SupplierStockItem", backref="supplier", lazy="dynamic", cascade="all, delete-orphan" + ) + + def __init__( + self, + code, + name, + created_by, + description=None, + contact_person=None, + email=None, + phone=None, + address=None, + website=None, + tax_id=None, + payment_terms=None, + currency_code="EUR", + is_active=True, + notes=None, + ): self.code = code.strip().upper() self.name = name.strip() self.created_by = created_by @@ -46,29 +63,28 @@ def __init__(self, code, name, created_by, description=None, contact_person=None self.currency_code = currency_code.upper() self.is_active = is_active self.notes = notes.strip() if notes else None - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert supplier to dictionary""" return { - 'id': self.id, - 'code': self.code, - 'name': self.name, - 'description': self.description, - 'contact_person': self.contact_person, - 'email': self.email, - 'phone': self.phone, - 'address': self.address, - 'website': self.website, - 'tax_id': self.tax_id, - 'payment_terms': self.payment_terms, - 'currency_code': self.currency_code, - 'is_active': self.is_active, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'created_by': self.created_by + "id": self.id, + "code": self.code, + "name": self.name, + "description": self.description, + "contact_person": self.contact_person, + "email": self.email, + "phone": self.phone, + "address": self.address, + "website": self.website, + "tax_id": self.tax_id, + "payment_terms": self.payment_terms, + "currency_code": self.currency_code, + "is_active": self.is_active, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "created_by": self.created_by, } - diff --git a/app/models/supplier_stock_item.py b/app/models/supplier_stock_item.py index 14303541..6b07e3e2 100644 --- a/app/models/supplier_stock_item.py +++ b/app/models/supplier_stock_item.py @@ -1,4 +1,5 @@ """SupplierStockItem model for many-to-many relationship between suppliers and stock items""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,18 +7,18 @@ class SupplierStockItem(db.Model): """SupplierStockItem model - links suppliers to stock items with pricing""" - - __tablename__ = 'supplier_stock_items' - + + __tablename__ = "supplier_stock_items" + id = db.Column(db.Integer, primary_key=True) - supplier_id = db.Column(db.Integer, db.ForeignKey('suppliers.id'), nullable=False, index=True) - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id'), nullable=False, index=True) - + supplier_id = db.Column(db.Integer, db.ForeignKey("suppliers.id"), nullable=False, index=True) + stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=False, index=True) + # Supplier-specific information for this item supplier_sku = db.Column(db.String(100), nullable=True) supplier_name = db.Column(db.String(200), nullable=True) # Supplier's name for this product unit_cost = db.Column(db.Numeric(10, 2), nullable=True) # Cost per unit from this supplier - currency_code = db.Column(db.String(3), nullable=False, default='EUR') + currency_code = db.Column(db.String(3), nullable=False, default="EUR") minimum_order_quantity = db.Column(db.Numeric(10, 2), nullable=True) # MOQ lead_time_days = db.Column(db.Integer, nullable=True) # Lead time in days is_preferred = db.Column(db.Boolean, default=False, nullable=False) # Preferred supplier for this item @@ -25,17 +26,26 @@ class SupplierStockItem(db.Model): notes = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships (backref defined in Supplier and StockItem models) - + # Unique constraint: one supplier-item relationship - __table_args__ = ( - db.UniqueConstraint('supplier_id', 'stock_item_id', name='uq_supplier_stock_item'), - ) - - def __init__(self, supplier_id, stock_item_id, supplier_sku=None, supplier_name=None, - unit_cost=None, currency_code='EUR', minimum_order_quantity=None, - lead_time_days=None, is_preferred=False, is_active=True, notes=None): + __table_args__ = (db.UniqueConstraint("supplier_id", "stock_item_id", name="uq_supplier_stock_item"),) + + def __init__( + self, + supplier_id, + stock_item_id, + supplier_sku=None, + supplier_name=None, + unit_cost=None, + currency_code="EUR", + minimum_order_quantity=None, + lead_time_days=None, + is_preferred=False, + is_active=True, + notes=None, + ): self.supplier_id = supplier_id self.stock_item_id = stock_item_id self.supplier_sku = supplier_sku.strip() if supplier_sku else None @@ -47,26 +57,25 @@ def __init__(self, supplier_id, stock_item_id, supplier_sku=None, supplier_name= self.is_preferred = is_preferred self.is_active = is_active self.notes = notes.strip() if notes else None - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert supplier stock item to dictionary""" return { - 'id': self.id, - 'supplier_id': self.supplier_id, - 'stock_item_id': self.stock_item_id, - 'supplier_sku': self.supplier_sku, - 'supplier_name': self.supplier_name, - 'unit_cost': float(self.unit_cost) if self.unit_cost else None, - 'currency_code': self.currency_code, - 'minimum_order_quantity': float(self.minimum_order_quantity) if self.minimum_order_quantity else None, - 'lead_time_days': self.lead_time_days, - 'is_preferred': self.is_preferred, - 'is_active': self.is_active, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "supplier_id": self.supplier_id, + "stock_item_id": self.stock_item_id, + "supplier_sku": self.supplier_sku, + "supplier_name": self.supplier_name, + "unit_cost": float(self.unit_cost) if self.unit_cost else None, + "currency_code": self.currency_code, + "minimum_order_quantity": float(self.minimum_order_quantity) if self.minimum_order_quantity else None, + "lead_time_days": self.lead_time_days, + "is_preferred": self.is_preferred, + "is_active": self.is_active, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - diff --git a/app/models/task.py b/app/models/task.py index 0adb75c2..9d76b840 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -2,35 +2,48 @@ from app import db from app.utils.timezone import now_in_app_timezone + class Task(db.Model): """Task model for breaking down projects into manageable components""" - - __tablename__ = 'tasks' - + + __tablename__ = "tasks" + id = db.Column(db.Integer, primary_key=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) name = db.Column(db.String(200), nullable=False, index=True) description = db.Column(db.Text, nullable=True) - status = db.Column(db.String(20), default='todo', nullable=False, index=True) # 'todo', 'in_progress', 'review', 'done', 'cancelled' - priority = db.Column(db.String(20), default='medium', nullable=False) # 'low', 'medium', 'high', 'urgent' + status = db.Column( + db.String(20), default="todo", nullable=False, index=True + ) # 'todo', 'in_progress', 'review', 'done', 'cancelled' + priority = db.Column(db.String(20), default="medium", nullable=False) # 'low', 'medium', 'high', 'urgent' estimated_hours = db.Column(db.Float, nullable=True) due_date = db.Column(db.Date, nullable=True, index=True) - assigned_to = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + assigned_to = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False) started_at = db.Column(db.DateTime, nullable=True) completed_at = db.Column(db.DateTime, nullable=True) - + # Relationships # project relationship is defined via backref in Project model - assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_tasks') - creator = db.relationship('User', foreign_keys=[created_by], backref='created_tasks') - time_entries = db.relationship('TimeEntry', backref='task', lazy='dynamic') + assigned_user = db.relationship("User", foreign_keys=[assigned_to], backref="assigned_tasks") + creator = db.relationship("User", foreign_keys=[created_by], backref="created_tasks") + time_entries = db.relationship("TimeEntry", backref="task", lazy="dynamic") # comments relationship is defined via backref in Comment model - - def __init__(self, project_id, name, description=None, priority='medium', estimated_hours=None, - due_date=None, assigned_to=None, created_by=None, status='todo'): + + def __init__( + self, + project_id, + name, + description=None, + priority="medium", + estimated_hours=None, + due_date=None, + assigned_to=None, + created_by=None, + status="todo", + ): self.project_id = project_id self.name = name.strip() self.description = description.strip() if description else None @@ -40,45 +53,51 @@ def __init__(self, project_id, name, description=None, priority='medium', estima self.assigned_to = assigned_to self.created_by = created_by self.status = status - + def __repr__(self): - return f'' - + return f"" + @property def is_active(self): """Check if task is active (not done or cancelled)""" - return self.status not in ['done', 'cancelled'] - + return self.status not in ["done", "cancelled"] + @property def is_overdue(self): """Check if task is overdue""" if not self.due_date: return False from datetime import date - return date.today() > self.due_date and self.status not in ['done', 'cancelled'] - + + return date.today() > self.due_date and self.status not in ["done", "cancelled"] + @property def total_hours(self): """Calculate total hours spent on this task""" try: - total_seconds = db.session.query( - db.func.sum( - db.func.coalesce( - db.func.extract('epoch', - db.func.greatest( - db.func.least( - db.func.coalesce(self.time_entries.end_time, now_in_app_timezone()), - now_in_app_timezone() - ) - self.time_entries.start_time - ) - ), - 0 + total_seconds = ( + db.session.query( + db.func.sum( + db.func.coalesce( + db.func.extract( + "epoch", + db.func.greatest( + db.func.least( + db.func.coalesce(self.time_entries.end_time, now_in_app_timezone()), + now_in_app_timezone(), + ) + - self.time_entries.start_time + ), + ), + 0, + ) ) ) - ).filter( - self.time_entries.project_id == self.project_id - ).scalar() or 0 - + .filter(self.time_entries.project_id == self.project_id) + .scalar() + or 0 + ) + return round(total_seconds / 3600, 2) except Exception: return 0.0 @@ -88,211 +107,204 @@ def total_billable_hours(self): """Calculate total billable hours spent on this task""" try: from .time_entry import TimeEntry - total_seconds = db.session.query( - db.func.sum(TimeEntry.duration_seconds) - ).filter( - TimeEntry.task_id == self.id, - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True - ).scalar() or 0 + + total_seconds = ( + db.session.query(db.func.sum(TimeEntry.duration_seconds)) + .filter(TimeEntry.task_id == self.id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True) + .scalar() + or 0 + ) return round(total_seconds / 3600, 2) except Exception: return 0.0 - + @property def progress_percentage(self): """Calculate progress percentage based on estimated vs actual hours""" if not self.estimated_hours or self.estimated_hours == 0: return 0 - + actual_hours = self.total_hours if actual_hours >= self.estimated_hours: return 100 - + return round((actual_hours / self.estimated_hours) * 100, 1) - + @property def status_display(self): """Get human-readable status from kanban columns""" from .kanban_column import KanbanColumn + column = KanbanColumn.get_column_by_key(self.status) if column: return column.label # Fallback to hardcoded map if column not found status_map = { - 'todo': 'To Do', - 'in_progress': 'In Progress', - 'review': 'Review', - 'done': 'Done', - 'cancelled': 'Cancelled' + "todo": "To Do", + "in_progress": "In Progress", + "review": "Review", + "done": "Done", + "cancelled": "Cancelled", } - return status_map.get(self.status, self.status.replace('_', ' ').title()) - + return status_map.get(self.status, self.status.replace("_", " ").title()) + @property def priority_display(self): """Get human-readable priority""" - priority_map = { - 'low': 'Low', - 'medium': 'Medium', - 'high': 'High', - 'urgent': 'Urgent' - } + priority_map = {"low": "Low", "medium": "Medium", "high": "High", "urgent": "Urgent"} return priority_map.get(self.priority, self.priority) - + @property def priority_class(self): """Get CSS class for priority styling""" priority_classes = { - 'low': 'priority-low', - 'medium': 'priority-medium', - 'high': 'priority-high', - 'urgent': 'priority-urgent' + "low": "priority-low", + "medium": "priority-medium", + "high": "priority-high", + "urgent": "priority-urgent", } - return priority_classes.get(self.priority, 'priority-medium') - + return priority_classes.get(self.priority, "priority-medium") + def start_task(self): """Mark task as in progress""" - if self.status == 'done': + if self.status == "done": raise ValueError("Cannot start a completed task") - - self.status = 'in_progress' + + self.status = "in_progress" self.started_at = now_in_app_timezone() self.updated_at = now_in_app_timezone() db.session.commit() - + def pause_task(self): """Pause task (mark as todo)""" - if self.status != 'in_progress': + if self.status != "in_progress": raise ValueError("Can only pause tasks that are in progress") - - self.status = 'todo' + + self.status = "todo" self.updated_at = now_in_app_timezone() db.session.commit() - + def mark_for_review(self): """Mark task as ready for review""" - if self.status not in ['in_progress', 'todo']: + if self.status not in ["in_progress", "todo"]: raise ValueError("Task must be in progress or todo to mark for review") - - self.status = 'review' + + self.status = "review" self.updated_at = now_in_app_timezone() db.session.commit() - + def complete_task(self): """Mark task as completed""" - if self.status == 'cancelled': + if self.status == "cancelled": raise ValueError("Cannot complete a cancelled task") - - self.status = 'done' + + self.status = "done" self.completed_at = now_in_app_timezone() self.updated_at = now_in_app_timezone() db.session.commit() - + def cancel_task(self): """Cancel the task""" - if self.status == 'done': + if self.status == "done": raise ValueError("Cannot cancel a completed task") - - self.status = 'cancelled' + + self.status = "cancelled" self.updated_at = now_in_app_timezone() db.session.commit() - + def reassign(self, user_id): """Reassign task to different user""" self.assigned_to = user_id self.updated_at = now_in_app_timezone() db.session.commit() - + def update_priority(self, priority): """Update task priority""" - valid_priorities = ['low', 'medium', 'high', 'urgent'] + valid_priorities = ["low", "medium", "high", "urgent"] if priority not in valid_priorities: raise ValueError(f"Invalid priority. Must be one of: {', '.join(valid_priorities)}") - + self.priority = priority self.updated_at = now_in_app_timezone() db.session.commit() - + def update_due_date(self, due_date): """Update task due date""" self.due_date = due_date self.updated_at = now_in_app_timezone() db.session.commit() - + def to_dict(self): """Convert task to dictionary for API responses""" return { - 'id': self.id, - 'project_id': self.project_id, - 'name': self.name, - 'description': self.description, - 'status': self.status, - 'status_display': self.status_display, - 'priority': self.priority, - 'priority_display': self.priority_display, - 'priority_class': self.priority_class, - 'estimated_hours': self.estimated_hours, - 'due_date': self.due_date.isoformat() if self.due_date else None, - 'assigned_to': self.assigned_to, - 'assigned_user': self.assigned_user.username if self.assigned_user else None, - 'created_by': self.created_by, - 'creator': self.creator.username if self.creator else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'completed_at': self.completed_at.isoformat() if self.completed_at else None, - 'total_hours': self.total_hours, - 'total_billable_hours': self.total_billable_hours, - 'progress_percentage': self.progress_percentage, - 'is_active': self.is_active, - 'is_overdue': self.is_overdue + "id": self.id, + "project_id": self.project_id, + "name": self.name, + "description": self.description, + "status": self.status, + "status_display": self.status_display, + "priority": self.priority, + "priority_display": self.priority_display, + "priority_class": self.priority_class, + "estimated_hours": self.estimated_hours, + "due_date": self.due_date.isoformat() if self.due_date else None, + "assigned_to": self.assigned_to, + "assigned_user": self.assigned_user.username if self.assigned_user else None, + "created_by": self.created_by, + "creator": self.creator.username if self.creator else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "total_hours": self.total_hours, + "total_billable_hours": self.total_billable_hours, + "progress_percentage": self.progress_percentage, + "is_active": self.is_active, + "is_overdue": self.is_overdue, } - + @classmethod def get_tasks_by_project(cls, project_id, status=None, priority=None): """Get tasks for a specific project with optional filters""" query = cls.query.filter_by(project_id=project_id) - + if status: query = query.filter_by(status=status) - + if priority: query = query.filter_by(priority=priority) - + return query.order_by(cls.priority.desc(), cls.due_date.asc(), cls.created_at.asc()).all() - + @classmethod def get_user_tasks(cls, user_id, status=None, include_assigned=True, include_created=True): """Get tasks for a specific user""" if not include_assigned and not include_created: return [] - + query = cls.query - + if include_assigned and include_created: - query = query.filter( - db.or_( - cls.assigned_to == user_id, - cls.created_by == user_id - ) - ) + query = query.filter(db.or_(cls.assigned_to == user_id, cls.created_by == user_id)) elif include_assigned: query = query.filter_by(assigned_to=user_id) elif include_created: query = query.filter_by(created_by=user_id) - + if status: query = query.filter_by(status=status) - + return query.order_by(cls.priority.desc(), cls.due_date.asc(), cls.created_at.asc()).all() - + @classmethod def get_overdue_tasks(cls): """Get all overdue tasks""" from datetime import date + today = date.today() - - return cls.query.filter( - cls.due_date < today, - cls.status.in_(['todo', 'in_progress', 'review']) - ).order_by(cls.priority.desc(), cls.due_date.asc()).all() + + return ( + cls.query.filter(cls.due_date < today, cls.status.in_(["todo", "in_progress", "review"])) + .order_by(cls.priority.desc(), cls.due_date.asc()) + .all() + ) diff --git a/app/models/task_activity.py b/app/models/task_activity.py index b895e6ed..714ed83f 100644 --- a/app/models/task_activity.py +++ b/app/models/task_activity.py @@ -4,17 +4,18 @@ class TaskActivity(db.Model): """Lightweight audit log for significant task events.""" - __tablename__ = 'task_activities' + + __tablename__ = "task_activities" id = db.Column(db.Integer, primary_key=True) - task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=False, index=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) event = db.Column(db.String(50), nullable=False, index=True) details = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False, index=True) - task = db.relationship('Task', backref=db.backref('activities', lazy='dynamic', cascade='all, delete-orphan')) - user = db.relationship('User') + task = db.relationship("Task", backref=db.backref("activities", lazy="dynamic", cascade="all, delete-orphan")) + user = db.relationship("User") def __init__(self, task_id, event, user_id=None, details=None): self.task_id = task_id @@ -23,6 +24,4 @@ def __init__(self, task_id, event, user_id=None, details=None): self.details = details def __repr__(self): - return f'' - - + return f"" diff --git a/app/models/tax_rule.py b/app/models/tax_rule.py index c27fdc97..eaf84bfa 100644 --- a/app/models/tax_rule.py +++ b/app/models/tax_rule.py @@ -5,14 +5,14 @@ class TaxRule(db.Model): """Flexible tax rules per country/region/client with effective date ranges.""" - __tablename__ = 'tax_rules' + __tablename__ = "tax_rules" id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(100), nullable=False) country = db.Column(db.String(2), nullable=True) # ISO-3166-1 alpha-2 region = db.Column(db.String(50), nullable=True) - client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) + client_id = db.Column(db.Integer, db.ForeignKey("clients.id"), nullable=True, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) tax_code = db.Column(db.String(50), nullable=True) # e.g., VAT, GST rate_percent = db.Column(db.Numeric(7, 4), nullable=False, default=0) compound = db.Column(db.Boolean, default=False, nullable=False) @@ -26,5 +26,3 @@ class TaxRule(db.Model): def __repr__(self): return f"" - - diff --git a/app/models/time_entry.py b/app/models/time_entry.py index a49b1239..b4457a10 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -3,39 +3,55 @@ from app.config import Config from app.utils.timezone import utc_to_local, local_to_utc + def local_now(): """Get current time in local timezone as naive datetime (for database storage)""" from app.utils.timezone import get_timezone_obj + tz = get_timezone_obj() now = datetime.now(tz) return now.replace(tzinfo=None) + class TimeEntry(db.Model): """Time entry model for manual and automatic time tracking""" - - __tablename__ = 'time_entries' - + + __tablename__ = "time_entries" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True) - task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=False, index=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) start_time = db.Column(db.DateTime, nullable=False, index=True) end_time = db.Column(db.DateTime, nullable=True, index=True) duration_seconds = db.Column(db.Integer, nullable=True) notes = db.Column(db.Text, nullable=True) tags = db.Column(db.String(500), nullable=True) # Comma-separated tags - source = db.Column(db.String(20), default='manual', nullable=False) # 'manual' or 'auto' + source = db.Column(db.String(20), default="manual", nullable=False) # 'manual' or 'auto' billable = db.Column(db.Boolean, default=True, nullable=False) created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Relationships # user and project relationships are defined via backref in their respective models # task relationship is defined via backref in Task model - - def __init__(self, user_id=None, project_id=None, start_time=None, end_time=None, task_id=None, notes=None, tags=None, source='manual', billable=True, duration_seconds=None, **kwargs): + + def __init__( + self, + user_id=None, + project_id=None, + start_time=None, + end_time=None, + task_id=None, + notes=None, + tags=None, + source="manual", + billable=True, + duration_seconds=None, + **kwargs, + ): """Initialize a TimeEntry instance. - + Args: user_id: ID of the user who created this entry project_id: ID of the project this entry is associated with @@ -59,89 +75,90 @@ def __init__(self, user_id=None, project_id=None, start_time=None, end_time=None self.start_time = start_time if end_time is not None: self.end_time = end_time - + self.notes = notes.strip() if notes else None self.tags = tags.strip() if tags else None self.source = source self.billable = billable - + # Allow manual duration override if duration_seconds is not None: self.duration_seconds = duration_seconds # Otherwise, calculate duration if end time is provided elif self.end_time: self.calculate_duration() - + def __repr__(self): - user_name = self.user.username if self.user else 'deleted_user' - project_name = self.project.name if self.project else 'deleted_project' - return f'' - + user_name = self.user.username if self.user else "deleted_user" + project_name = self.project.name if self.project else "deleted_project" + return f"" + @property def is_active(self): """Check if this is an active timer (no end time)""" return self.end_time is None - + @property def duration_hours(self): """Get duration in hours""" if not self.duration_seconds: return 0 return round(self.duration_seconds / 3600, 2) - + @property def duration_formatted(self): """Get duration formatted as HH:MM:SS""" if not self.duration_seconds: return "00:00:00" - + # 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 - + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" - + @property def tag_list(self): """Get tags as a list""" if not self.tags: return [] - return [tag.strip() for tag in self.tags.split(',') if tag.strip()] - + return [tag.strip() for tag in self.tags.split(",") if tag.strip()] + @property def current_duration_seconds(self): """Calculate current duration for active timers""" if self.end_time: return self.duration_seconds or 0 - + # For active timers, calculate from start time to now # Since we store everything in local timezone, we can work with naive datetimes # as long as we treat them as local time - + # Get current time in local timezone (naive, matching database storage) now_local = local_now() - + # Calculate duration (both times are treated as local time) duration = now_local - self.start_time return int(duration.total_seconds()) - + def calculate_duration(self): """Calculate and set duration in seconds with rounding""" if not self.end_time: return - + # Since we store everything in local timezone, we can work with naive datetimes # as long as we treat them as local time - + # Calculate raw duration (both times are treated as local time) duration = self.end_time - self.start_time raw_seconds = int(duration.total_seconds()) - + # Apply per-user rounding if user preferences are set - if self.user and hasattr(self.user, 'time_rounding_enabled'): + if self.user and hasattr(self.user, "time_rounding_enabled"): from app.utils.time_rounding import apply_user_rounding + self.duration_seconds = apply_user_rounding(raw_seconds, self.user) else: # Fallback to global rounding setting for backward compatibility @@ -153,114 +170,116 @@ def calculate_duration(self): self.duration_seconds = int(rounded_minutes * 60) else: self.duration_seconds = raw_seconds - + def stop_timer(self, end_time=None): """Stop an active timer""" if self.end_time: raise ValueError("Timer is already stopped") - + # Use local timezone for consistency with database storage if end_time: self.end_time = end_time else: self.end_time = local_now() - + self.calculate_duration() self.updated_at = local_now() - + db.session.commit() - + def update_notes(self, notes): """Update notes for this entry""" self.notes = notes.strip() if notes else None self.updated_at = local_now() db.session.commit() - + def update_tags(self, tags): """Update tags for this entry""" self.tags = tags.strip() if tags else None self.updated_at = local_now() db.session.commit() - + def set_billable(self, billable): """Set billable status""" self.billable = billable self.updated_at = local_now() db.session.commit() - + def to_dict(self): """Convert time entry to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'project_id': self.project_id, - 'task_id': self.task_id, - 'start_time': self.start_time.isoformat() if self.start_time else None, - 'end_time': self.end_time.isoformat() if self.end_time else None, - 'duration_seconds': self.duration_seconds, - 'duration_hours': self.duration_hours, - 'duration_formatted': self.duration_formatted, - 'notes': self.notes, - 'tags': self.tags, - 'tag_list': self.tag_list, - 'source': self.source, - 'billable': self.billable, - 'is_active': self.is_active, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'user': self.user.username if self.user else None, - 'project': self.project.name if self.project else None, - 'task': self.task.name if self.task else None + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "task_id": self.task_id, + "start_time": self.start_time.isoformat() if self.start_time else None, + "end_time": self.end_time.isoformat() if self.end_time else None, + "duration_seconds": self.duration_seconds, + "duration_hours": self.duration_hours, + "duration_formatted": self.duration_formatted, + "notes": self.notes, + "tags": self.tags, + "tag_list": self.tag_list, + "source": self.source, + "billable": self.billable, + "is_active": self.is_active, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "user": self.user.username if self.user else None, + "project": self.project.name if self.project else None, + "task": self.task.name if self.task else None, } - + @classmethod def get_active_timers(cls): """Get all active timers""" return cls.query.filter_by(end_time=None).all() - + @classmethod def get_user_active_timer(cls, user_id): """Get active timer for a specific user""" return cls.query.filter_by(user_id=user_id, end_time=None).first() - + @classmethod def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None): """Get time entries for a specific period with optional filters""" query = cls.query.filter(cls.end_time.isnot(None)) - + if start_date: query = query.filter(cls.start_time >= start_date) - + if end_date: query = query.filter(cls.start_time <= end_date) - + if user_id: query = query.filter(cls.user_id == user_id) - + if project_id: query = query.filter(cls.project_id == project_id) - + return query.order_by(cls.start_time.desc()).all() - + @classmethod - def get_total_hours_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False): + def get_total_hours_for_period( + cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False + ): """Calculate total hours for a period with optional filters""" query = db.session.query(db.func.sum(cls.duration_seconds)) - + if start_date: query = query.filter(cls.start_time >= start_date) - + if end_date: query = query.filter(cls.start_time <= end_date) - + if user_id: query = query.filter(cls.user_id == user_id) - + if project_id: query = query.filter(cls.project_id == project_id) - + if billable_only: query = query.filter(cls.billable == True) - + total_seconds = query.scalar() or 0 return round(total_seconds / 3600, 2) diff --git a/app/models/time_entry_template.py b/app/models/time_entry_template.py index 06c910a1..d85014ae 100644 --- a/app/models/time_entry_template.py +++ b/app/models/time_entry_template.py @@ -4,47 +4,47 @@ class TimeEntryTemplate(db.Model): """Quick-start templates for common time entries - + Allows users to create reusable templates for frequently logged activities, saving time and ensuring consistency. """ - - __tablename__ = 'time_entry_templates' - + + __tablename__ = "time_entry_templates" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - + # Default values for time entries - project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True) - task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id"), nullable=True, index=True) + task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) default_duration_minutes = db.Column(db.Integer, nullable=True) # Optional default duration default_notes = db.Column(db.Text, nullable=True) tags = db.Column(db.String(500), nullable=True) # Comma-separated tags billable = db.Column(db.Boolean, default=True, nullable=False) - + # Metadata usage_count = db.Column(db.Integer, default=0, nullable=False) # Track how often used last_used_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - user = db.relationship('User', backref='time_entry_templates') - project = db.relationship('Project', backref='time_entry_templates') - task = db.relationship('Task', backref='time_entry_templates') - + user = db.relationship("User", backref="time_entry_templates") + project = db.relationship("Project", backref="time_entry_templates") + task = db.relationship("Task", backref="time_entry_templates") + def __repr__(self): - return f'' - + return f"" + @property def default_duration(self): """Get duration in hours""" if self.default_duration_minutes is None: return None return self.default_duration_minutes / 60.0 - + @default_duration.setter def default_duration(self, hours): """Set duration from hours""" @@ -52,37 +52,36 @@ def default_duration(self, hours): self.default_duration_minutes = None else: self.default_duration_minutes = int(hours * 60) - + def record_usage(self): """Record that this template was used""" self.usage_count += 1 self.last_used_at = datetime.utcnow() - + def increment_usage(self): """Increment usage count and update last used timestamp""" self.usage_count += 1 self.last_used_at = datetime.utcnow() db.session.commit() - + def to_dict(self): """Convert to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'name': self.name, - 'description': self.description, - 'project_id': self.project_id, - 'project_name': self.project.name if self.project else None, - 'task_id': self.task_id, - 'task_name': self.task.name if self.task else None, - 'default_duration': self.default_duration, # In hours for API - 'default_duration_minutes': self.default_duration_minutes, # Keep for compatibility - 'default_notes': self.default_notes, - 'tags': self.tags, - 'billable': self.billable, - 'usage_count': self.usage_count, - 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "user_id": self.user_id, + "name": self.name, + "description": self.description, + "project_id": self.project_id, + "project_name": self.project.name if self.project else None, + "task_id": self.task_id, + "task_name": self.task.name if self.task else None, + "default_duration": self.default_duration, # In hours for API + "default_duration_minutes": self.default_duration_minutes, # Keep for compatibility + "default_notes": self.default_notes, + "tags": self.tags, + "billable": self.billable, + "usage_count": self.usage_count, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - diff --git a/app/models/user.py b/app/models/user.py index 42559e57..1e15594a 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -4,19 +4,18 @@ from app import db import os + class User(UserMixin, db.Model): """User model for username-based authentication""" - - __tablename__ = 'users' - __table_args__ = ( - db.UniqueConstraint('oidc_issuer', 'oidc_sub', name='uq_users_oidc_issuer_sub'), - ) - + + __tablename__ = "users" + __table_args__ = (db.UniqueConstraint("oidc_issuer", "oidc_sub", name="uq_users_oidc_issuer_sub"),) + id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) email = db.Column(db.String(200), nullable=True, index=True) full_name = db.Column(db.String(200), nullable=True) - role = db.Column(db.String(20), default='user', nullable=False) # 'user' or 'admin' + role = db.Column(db.String(20), default="user", nullable=False) # 'user' or 'admin' created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) last_login = db.Column(db.DateTime, nullable=True) is_active = db.Column(db.Boolean, default=True, nullable=False) @@ -26,7 +25,7 @@ class User(UserMixin, db.Model): oidc_issuer = db.Column(db.String(255), nullable=True) avatar_filename = db.Column(db.String(255), nullable=True) password_hash = db.Column(db.String(255), nullable=True) - + # User preferences and settings email_notifications = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable email notifications notification_overdue_invoices = db.Column(db.Boolean, default=True, nullable=False) # Notify about overdue invoices @@ -34,41 +33,50 @@ class User(UserMixin, db.Model): notification_task_comments = db.Column(db.Boolean, default=True, nullable=False) # Notify about task comments notification_weekly_summary = db.Column(db.Boolean, default=False, nullable=False) # Send weekly time summary timezone = db.Column(db.String(50), nullable=True) # User-specific timezone override - date_format = db.Column(db.String(20), default='YYYY-MM-DD', nullable=False) # Date format preference - time_format = db.Column(db.String(10), default='24h', nullable=False) # '12h' or '24h' + date_format = db.Column(db.String(20), default="YYYY-MM-DD", nullable=False) # Date format preference + time_format = db.Column(db.String(10), default="24h", nullable=False) # '12h' or '24h' week_start_day = db.Column(db.Integer, default=1, nullable=False) # 0=Sunday, 1=Monday, etc. - + # Time rounding preferences time_rounding_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable time rounding time_rounding_minutes = db.Column(db.Integer, default=1, nullable=False) # Rounding interval: 1, 5, 10, 15, 30, 60 - time_rounding_method = db.Column(db.String(10), default='nearest', nullable=False) # 'nearest', 'up', or 'down' - + time_rounding_method = db.Column(db.String(10), default="nearest", nullable=False) # 'nearest', 'up', or 'down' + # Overtime settings - standard_hours_per_day = db.Column(db.Float, default=8.0, nullable=False) # Standard working hours per day for overtime calculation - + standard_hours_per_day = db.Column( + db.Float, default=8.0, nullable=False + ) # Standard working hours per day for overtime calculation + # Client portal settings client_portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access - client_id = db.Column(db.Integer, db.ForeignKey('clients.id', ondelete='SET NULL'), nullable=True, index=True) # Link user to a client for portal access - + client_id = db.Column( + db.Integer, db.ForeignKey("clients.id", ondelete="SET NULL"), nullable=True, index=True + ) # Link user to a client for portal access + # Relationships - time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan') - project_costs = db.relationship('ProjectCost', backref='user', lazy='dynamic', cascade='all, delete-orphan') - favorite_projects = db.relationship('Project', secondary='user_favorite_projects', lazy='dynamic', backref=db.backref('favorited_by', lazy='dynamic')) - roles = db.relationship('Role', secondary='user_roles', lazy='joined', backref=db.backref('users', lazy='dynamic')) - client = db.relationship('Client', backref='portal_users', lazy='joined') - - def __init__(self, username, role='user', email=None, full_name=None): + time_entries = db.relationship("TimeEntry", backref="user", lazy="dynamic", cascade="all, delete-orphan") + project_costs = db.relationship("ProjectCost", backref="user", lazy="dynamic", cascade="all, delete-orphan") + favorite_projects = db.relationship( + "Project", + secondary="user_favorite_projects", + lazy="dynamic", + backref=db.backref("favorited_by", lazy="dynamic"), + ) + roles = db.relationship("Role", secondary="user_roles", lazy="joined", backref=db.backref("users", lazy="dynamic")) + client = db.relationship("Client", backref="portal_users", lazy="joined") + + def __init__(self, username, role="user", email=None, full_name=None): self.username = username.lower().strip() self.role = role - self.email = (email or None) - self.full_name = (full_name or None) + self.email = email or None + self.full_name = full_name or None # Set default for standard_hours_per_day if not set by SQLAlchemy - if not hasattr(self, 'standard_hours_per_day') or self.standard_hours_per_day is None: + if not hasattr(self, "standard_hours_per_day") or self.standard_hours_per_day is None: self.standard_hours_per_day = 8.0 - + def __repr__(self): - return f'' - + return f"" + def set_password(self, password): """ Set the user's password hash. @@ -78,7 +86,7 @@ def set_password(self, password): self.password_hash = generate_password_hash(password) else: self.password_hash = None - + def check_password(self, password): """ Check if the provided password matches the user's password hash. @@ -87,40 +95,39 @@ def check_password(self, password): if not self.password_hash or not password: return False return check_password_hash(self.password_hash, password) - + @property def has_password(self): """Check if user has a password set""" return bool(self.password_hash) - + @property def is_admin(self): """Check if user is an admin""" # Backward compatibility: check legacy role field first - if self.role == 'admin': + if self.role == "admin": return True # Check if user has any admin role - return any(role.name in ['admin', 'super_admin'] for role in self.roles) - + return any(role.name in ["admin", "super_admin"] for role in self.roles) + @property def active_timer(self): """Get the user's currently active timer""" from .time_entry import TimeEntry - return TimeEntry.query.filter_by( - user_id=self.id, - end_time=None - ).first() - + + return TimeEntry.query.filter_by(user_id=self.id, end_time=None).first() + @property def total_hours(self): """Calculate total hours worked by this user""" from .time_entry import TimeEntry - total_seconds = db.session.query( - db.func.sum(TimeEntry.duration_seconds) - ).filter( - TimeEntry.user_id == self.id, - TimeEntry.end_time.isnot(None) - ).scalar() or 0 + + total_seconds = ( + db.session.query(db.func.sum(TimeEntry.duration_seconds)) + .filter(TimeEntry.user_id == self.id, TimeEntry.end_time.isnot(None)) + .scalar() + or 0 + ) return round(total_seconds / 3600, 2) @property @@ -129,35 +136,37 @@ def display_name(self): if self.full_name and self.full_name.strip(): return self.full_name.strip() return self.username - + def get_recent_entries(self, limit=10): """Get recent time entries for this user""" from .time_entry import TimeEntry - return self.time_entries.filter( - TimeEntry.end_time.isnot(None) - ).order_by( - TimeEntry.start_time.desc() - ).limit(limit).all() - + + return ( + self.time_entries.filter(TimeEntry.end_time.isnot(None)) + .order_by(TimeEntry.start_time.desc()) + .limit(limit) + .all() + ) + def update_last_login(self): """Update the last login timestamp""" self.last_login = datetime.utcnow() db.session.commit() - + def to_dict(self): """Convert user to dictionary for API responses""" return { - 'id': self.id, - 'username': self.username, - 'email': self.email, - 'full_name': self.full_name, - 'display_name': self.display_name, - 'role': self.role, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'last_login': self.last_login.isoformat() if self.last_login else None, - 'is_active': self.is_active, - 'total_hours': self.total_hours, - 'avatar_url': self.get_avatar_url(), + "id": self.id, + "username": self.username, + "email": self.email, + "full_name": self.full_name, + "display_name": self.display_name, + "role": self.role, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_login": self.last_login.isoformat() if self.last_login else None, + "is_active": self.is_active, + "total_hours": self.total_hours, + "avatar_url": self.get_avatar_url(), } # Avatar helpers @@ -173,80 +182,82 @@ def get_avatar_path(self): return None try: from flask import current_app + # Avatars are now stored in /data volume to persist between container updates - upload_folder = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'avatars') + upload_folder = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "avatars") return os.path.join(upload_folder, self.avatar_filename) except Exception: # Fallback for development/non-docker environments - return os.path.join('/data/uploads', 'avatars', self.avatar_filename) + return os.path.join("/data/uploads", "avatars", self.avatar_filename) def has_avatar(self): """Check whether the user's avatar file exists on disk""" path = self.get_avatar_path() return bool(path and os.path.exists(path)) - + # Favorite projects helpers def add_favorite_project(self, project): """Add a project to user's favorites""" if not self.is_project_favorite(project): self.favorite_projects.append(project) db.session.commit() - + def remove_favorite_project(self, project): """Remove a project from user's favorites""" if self.is_project_favorite(project): self.favorite_projects.remove(project) db.session.commit() - + def is_project_favorite(self, project): """Check if a project is in user's favorites""" from .project import Project + if isinstance(project, int): project_id = project return self.favorite_projects.filter_by(id=project_id).count() > 0 elif isinstance(project, Project): return self.favorite_projects.filter_by(id=project.id).count() > 0 return False - - def get_favorite_projects(self, status='active'): + + def get_favorite_projects(self, status="active"): """Get user's favorite projects, optionally filtered by status""" query = self.favorite_projects if status: query = query.filter_by(status=status) - return query.order_by('name').all() - + return query.order_by("name").all() + # Permission and role helpers def has_permission(self, permission_name): """Check if user has a specific permission through any of their roles""" # Super admin users have all permissions - if self.role == 'admin' and not self.roles: + if self.role == "admin" and not self.roles: # Legacy admin users without roles have all permissions return True - + # Check if any of the user's roles have this permission for role in self.roles: if role.has_permission(permission_name): return True return False - + def has_any_permission(self, *permission_names): """Check if user has any of the specified permissions""" return any(self.has_permission(perm) for perm in permission_names) - + def has_all_permissions(self, *permission_names): """Check if user has all of the specified permissions""" return all(self.has_permission(perm) for perm in permission_names) - + def add_role(self, role): """Add a role to this user""" if role not in self.roles: self.roles.append(role) - + def remove_role(self, role): """Remove a role from this user""" if role in self.roles: self.roles.remove(role) - + def get_all_permissions(self): """Get all permissions this user has through their roles""" permissions = set() @@ -254,57 +265,49 @@ def get_all_permissions(self): for permission in role.permissions: permissions.add(permission) return list(permissions) - + def get_role_names(self): """Get list of role names for this user""" return [r.name for r in self.roles] - + # Client portal helpers @property def is_client_portal_user(self): """Check if user has client portal access enabled""" return self.client_portal_enabled and self.client_id is not None - + def get_client_portal_data(self): """Get data for client portal view (projects, invoices, time entries for assigned client)""" if not self.is_client_portal_user: return None - + from .project import Project from .invoice import Invoice from .time_entry import TimeEntry from .client import Client - + # Get client - try relationship first, then query by ID if needed client = self.client if not client and self.client_id: # Relationship might not be loaded, query directly client = Client.query.get(self.client_id) - + if not client: return None - + # Get active projects for this client - projects = Project.query.filter_by( - client_id=client.id, - status='active' - ).order_by(Project.name).all() - + projects = Project.query.filter_by(client_id=client.id, status="active").order_by(Project.name).all() + # Get invoices for this client - invoices = Invoice.query.filter_by( - client_id=client.id - ).order_by(Invoice.issue_date.desc()).limit(50).all() - + invoices = Invoice.query.filter_by(client_id=client.id).order_by(Invoice.issue_date.desc()).limit(50).all() + # Get time entries for projects belonging to this client project_ids = [p.id for p in projects] - time_entries = TimeEntry.query.filter( - TimeEntry.project_id.in_(project_ids), - TimeEntry.end_time.isnot(None) - ).order_by(TimeEntry.start_time.desc()).limit(100).all() - - return { - 'client': client, - 'projects': projects, - 'invoices': invoices, - 'time_entries': time_entries - } + time_entries = ( + TimeEntry.query.filter(TimeEntry.project_id.in_(project_ids), TimeEntry.end_time.isnot(None)) + .order_by(TimeEntry.start_time.desc()) + .limit(100) + .all() + ) + + return {"client": client, "projects": projects, "invoices": invoices, "time_entries": time_entries} diff --git a/app/models/user_favorite_project.py b/app/models/user_favorite_project.py index 48d6a51a..8182d6af 100644 --- a/app/models/user_favorite_project.py +++ b/app/models/user_favorite_project.py @@ -1,30 +1,28 @@ from datetime import datetime from app import db + class UserFavoriteProject(db.Model): """Association table for user favorite projects""" - - __tablename__ = 'user_favorite_projects' - + + __tablename__ = "user_favorite_projects" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True) - project_id = db.Column(db.Integer, db.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + project_id = db.Column(db.Integer, db.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - + # Unique constraint to prevent duplicate favorites - __table_args__ = ( - db.UniqueConstraint('user_id', 'project_id', name='uq_user_project_favorite'), - ) - + __table_args__ = (db.UniqueConstraint("user_id", "project_id", name="uq_user_project_favorite"),) + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'project_id': self.project_id, - 'created_at': self.created_at.isoformat() if self.created_at else None, + "id": self.id, + "user_id": self.user_id, + "project_id": self.project_id, + "created_at": self.created_at.isoformat() if self.created_at else None, } - diff --git a/app/models/warehouse.py b/app/models/warehouse.py index 355b1e9c..2d77ee6e 100644 --- a/app/models/warehouse.py +++ b/app/models/warehouse.py @@ -1,13 +1,14 @@ """Warehouse model for inventory management""" + from datetime import datetime from app import db class Warehouse(db.Model): """Warehouse model - represents a storage location""" - - __tablename__ = 'warehouses' - + + __tablename__ = "warehouses" + id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(200), nullable=False) code = db.Column(db.String(50), unique=True, nullable=False, index=True) @@ -19,14 +20,26 @@ class Warehouse(db.Model): notes = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - + created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + # Relationships - stock_levels = db.relationship('WarehouseStock', backref='warehouse', lazy='dynamic', cascade='all, delete-orphan') - stock_movements = db.relationship('StockMovement', backref='warehouse', lazy='dynamic', cascade='all, delete-orphan') - - def __init__(self, name, code, created_by, address=None, contact_person=None, - contact_email=None, contact_phone=None, is_active=True, notes=None): + stock_levels = db.relationship("WarehouseStock", backref="warehouse", lazy="dynamic", cascade="all, delete-orphan") + stock_movements = db.relationship( + "StockMovement", backref="warehouse", lazy="dynamic", cascade="all, delete-orphan" + ) + + def __init__( + self, + name, + code, + created_by, + address=None, + contact_person=None, + contact_email=None, + contact_phone=None, + is_active=True, + notes=None, + ): self.name = name.strip() self.code = code.strip().upper() self.created_by = created_by @@ -36,24 +49,23 @@ def __init__(self, name, code, created_by, address=None, contact_person=None, self.contact_phone = contact_phone.strip() if contact_phone else None self.is_active = is_active self.notes = notes.strip() if notes else None - + def __repr__(self): - return f'' - + return f"" + def to_dict(self): """Convert warehouse to dictionary""" return { - 'id': self.id, - 'name': self.name, - 'code': self.code, - 'address': self.address, - 'contact_person': self.contact_person, - 'contact_email': self.contact_email, - 'contact_phone': self.contact_phone, - 'is_active': self.is_active, - 'notes': self.notes, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, - 'created_by': self.created_by + "id": self.id, + "name": self.name, + "code": self.code, + "address": self.address, + "contact_person": self.contact_person, + "contact_email": self.contact_email, + "contact_phone": self.contact_phone, + "is_active": self.is_active, + "notes": self.notes, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "created_by": self.created_by, } - diff --git a/app/models/warehouse_stock.py b/app/models/warehouse_stock.py index 793c6875..05b0b5ec 100644 --- a/app/models/warehouse_stock.py +++ b/app/models/warehouse_stock.py @@ -1,4 +1,5 @@ """WarehouseStock model for tracking stock levels per warehouse""" + from datetime import datetime from decimal import Decimal from app import db @@ -6,43 +7,43 @@ class WarehouseStock(db.Model): """WarehouseStock model - tracks stock levels per warehouse""" - - __tablename__ = 'warehouse_stock' - + + __tablename__ = "warehouse_stock" + id = db.Column(db.Integer, primary_key=True) - warehouse_id = db.Column(db.Integer, db.ForeignKey('warehouses.id', ondelete='CASCADE'), nullable=False, index=True) - stock_item_id = db.Column(db.Integer, db.ForeignKey('stock_items.id', ondelete='CASCADE'), nullable=False, index=True) + warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id", ondelete="CASCADE"), nullable=False, index=True) + stock_item_id = db.Column( + db.Integer, db.ForeignKey("stock_items.id", ondelete="CASCADE"), nullable=False, index=True + ) quantity_on_hand = db.Column(db.Numeric(10, 2), nullable=False, default=0) quantity_reserved = db.Column(db.Numeric(10, 2), nullable=False, default=0) location = db.Column(db.String(100), nullable=True) last_counted_at = db.Column(db.DateTime, nullable=True) - last_counted_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) + last_counted_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) - + # Relationships - counted_by_user = db.relationship('User', foreign_keys=[last_counted_by]) - + counted_by_user = db.relationship("User", foreign_keys=[last_counted_by]) + # Unique constraint: one stock record per item per warehouse - __table_args__ = ( - db.UniqueConstraint('warehouse_id', 'stock_item_id', name='uq_warehouse_stock'), - ) - + __table_args__ = (db.UniqueConstraint("warehouse_id", "stock_item_id", name="uq_warehouse_stock"),) + def __init__(self, warehouse_id, stock_item_id, quantity_on_hand=0, quantity_reserved=0, location=None): self.warehouse_id = warehouse_id self.stock_item_id = stock_item_id self.quantity_on_hand = Decimal(str(quantity_on_hand)) self.quantity_reserved = Decimal(str(quantity_reserved)) self.location = location.strip() if location else None - + def __repr__(self): - return f'' - + return f"" + @property def quantity_available(self): """Calculate available quantity (on-hand minus reserved)""" return self.quantity_on_hand - self.quantity_reserved - + def reserve(self, quantity): """Reserve quantity""" qty = Decimal(str(quantity)) @@ -51,7 +52,7 @@ def reserve(self, quantity): raise ValueError(f"Insufficient stock. Available: {available}, Requested: {qty}") self.quantity_reserved += qty self.updated_at = datetime.utcnow() - + def release_reservation(self, quantity): """Release reserved quantity""" qty = Decimal(str(quantity)) @@ -59,15 +60,15 @@ def release_reservation(self, quantity): raise ValueError(f"Cannot release more than reserved. Reserved: {self.quantity_reserved}, Requested: {qty}") self.quantity_reserved -= qty self.updated_at = datetime.utcnow() - + def adjust_on_hand(self, quantity): """Adjust on-hand quantity (positive for additions, negative for removals)""" qty = Decimal(str(quantity)) self.quantity_on_hand += qty if self.quantity_on_hand < 0: - self.quantity_on_hand = Decimal('0') + self.quantity_on_hand = Decimal("0") self.updated_at = datetime.utcnow() - + def record_count(self, counted_quantity, counted_by=None): """Record a physical count""" self.quantity_on_hand = Decimal(str(counted_quantity)) @@ -75,20 +76,19 @@ def record_count(self, counted_quantity, counted_by=None): if counted_by: self.last_counted_by = counted_by self.updated_at = datetime.utcnow() - + def to_dict(self): """Convert warehouse stock to dictionary""" return { - 'id': self.id, - 'warehouse_id': self.warehouse_id, - 'stock_item_id': self.stock_item_id, - 'quantity_on_hand': float(self.quantity_on_hand), - 'quantity_reserved': float(self.quantity_reserved), - 'quantity_available': float(self.quantity_available), - 'location': self.location, - 'last_counted_at': self.last_counted_at.isoformat() if self.last_counted_at else None, - 'last_counted_by': self.last_counted_by, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None + "id": self.id, + "warehouse_id": self.warehouse_id, + "stock_item_id": self.stock_item_id, + "quantity_on_hand": float(self.quantity_on_hand), + "quantity_reserved": float(self.quantity_reserved), + "quantity_available": float(self.quantity_available), + "location": self.location, + "last_counted_at": self.last_counted_at.isoformat() if self.last_counted_at else None, + "last_counted_by": self.last_counted_by, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - diff --git a/app/models/webhook.py b/app/models/webhook.py index 90bc9774..621b78dd 100644 --- a/app/models/webhook.py +++ b/app/models/webhook.py @@ -1,4 +1,5 @@ """Webhook models for enabling integrations""" + import secrets import hashlib import hmac @@ -10,40 +11,40 @@ class Webhook(db.Model): """Webhook configuration for sending events to external systems""" - - __tablename__ = 'webhooks' - + + __tablename__ = "webhooks" + id = db.Column(db.Integer, primary_key=True) - + # Basic information name = db.Column(db.String(200), nullable=False) description = db.Column(db.Text, nullable=True) - + # Webhook URL and configuration url = db.Column(db.String(500), nullable=False) secret = db.Column(db.String(128), nullable=True) # Secret for HMAC signature - + # Event subscriptions (JSON array of event types) # Examples: ['project.created', 'time_entry.started', 'invoice.paid'] events = db.Column(db.JSON, nullable=False, default=list) - + # HTTP configuration - http_method = db.Column(db.String(10), default='POST', nullable=False) # POST, PUT, PATCH - content_type = db.Column(db.String(50), default='application/json', nullable=False) + http_method = db.Column(db.String(10), default="POST", nullable=False) # POST, PUT, PATCH + content_type = db.Column(db.String(50), default="application/json", nullable=False) headers = db.Column(db.JSON, nullable=True) # Custom headers as JSON object - + # Status and ownership is_active = db.Column(db.Boolean, default=True, nullable=False, index=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) - user = db.relationship('User', backref='webhooks') - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) + user = db.relationship("User", backref="webhooks") + # Retry configuration max_retries = db.Column(db.Integer, default=3, nullable=False) retry_delay_seconds = db.Column(db.Integer, default=60, nullable=False) # Delay between retries - + # Timeout configuration timeout_seconds = db.Column(db.Integer, default=30, nullable=False) - + # Statistics total_deliveries = db.Column(db.Integer, default=0, nullable=False) successful_deliveries = db.Column(db.Integer, default=0, nullable=False) @@ -51,208 +52,204 @@ class Webhook(db.Model): last_delivery_at = db.Column(db.DateTime, nullable=True) last_success_at = db.Column(db.DateTime, nullable=True) last_failure_at = db.Column(db.DateTime, nullable=True) - + # Timestamps created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False) - + # Indexes __table_args__ = ( - db.Index('ix_webhooks_user_id', 'user_id'), - db.Index('ix_webhooks_is_active', 'is_active'), - db.Index('ix_webhooks_created_at', 'created_at'), + db.Index("ix_webhooks_user_id", "user_id"), + db.Index("ix_webhooks_is_active", "is_active"), + db.Index("ix_webhooks_created_at", "created_at"), ) - + def __repr__(self): - return f'' - + return f"" + @staticmethod def generate_secret(): """Generate a secure random secret for webhook signing""" return secrets.token_urlsafe(32) - + def set_secret(self, secret=None): """Set or generate a webhook secret""" if secret is None: secret = self.generate_secret() self.secret = secret - + def verify_signature(self, payload, signature): """Verify HMAC signature of webhook payload - + Args: payload: The webhook payload (string or bytes) signature: The signature header value - + Returns: bool: True if signature is valid """ if not self.secret: return False - + if isinstance(payload, str): - payload = payload.encode('utf-8') - - expected_signature = hmac.new( - self.secret.encode('utf-8'), - payload, - hashlib.sha256 - ).hexdigest() - + payload = payload.encode("utf-8") + + expected_signature = hmac.new(self.secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() + # Support both 'sha256=...' and plain hex formats - if signature.startswith('sha256='): + if signature.startswith("sha256="): signature = signature[7:] - + return hmac.compare_digest(expected_signature, signature) - + def generate_signature(self, payload): """Generate HMAC signature for webhook payload - + Args: payload: The webhook payload (string or bytes) - + Returns: str: HMAC signature in format 'sha256=...' """ if not self.secret: return None - + if isinstance(payload, str): - payload = payload.encode('utf-8') - - signature = hmac.new( - self.secret.encode('utf-8'), - payload, - hashlib.sha256 - ).hexdigest() - - return f'sha256={signature}' - + payload = payload.encode("utf-8") + + signature = hmac.new(self.secret.encode("utf-8"), payload, hashlib.sha256).hexdigest() + + return f"sha256={signature}" + def subscribes_to(self, event_type): """Check if webhook subscribes to a specific event type - + Args: event_type: Event type string (e.g., 'project.created') - + Returns: bool: True if webhook subscribes to this event """ if not self.events: return False - return event_type in self.events or '*' in self.events - + return event_type in self.events or "*" in self.events + def to_dict(self, include_secret=False): """Convert to dictionary for API responses""" data = { - 'id': self.id, - 'name': self.name, - 'description': self.description, - 'url': self.url, - 'events': self.events or [], - 'http_method': self.http_method, - 'content_type': self.content_type, - 'headers': self.headers or {}, - 'is_active': self.is_active, - 'user_id': self.user_id, - 'max_retries': self.max_retries, - 'retry_delay_seconds': self.retry_delay_seconds, - 'timeout_seconds': self.timeout_seconds, - 'total_deliveries': self.total_deliveries, - 'successful_deliveries': self.successful_deliveries, - 'failed_deliveries': self.failed_deliveries, - 'last_delivery_at': self.last_delivery_at.isoformat() if self.last_delivery_at else None, - 'last_success_at': self.last_success_at.isoformat() if self.last_success_at else None, - 'last_failure_at': self.last_failure_at.isoformat() if self.last_failure_at else None, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "name": self.name, + "description": self.description, + "url": self.url, + "events": self.events or [], + "http_method": self.http_method, + "content_type": self.content_type, + "headers": self.headers or {}, + "is_active": self.is_active, + "user_id": self.user_id, + "max_retries": self.max_retries, + "retry_delay_seconds": self.retry_delay_seconds, + "timeout_seconds": self.timeout_seconds, + "total_deliveries": self.total_deliveries, + "successful_deliveries": self.successful_deliveries, + "failed_deliveries": self.failed_deliveries, + "last_delivery_at": self.last_delivery_at.isoformat() if self.last_delivery_at else None, + "last_success_at": self.last_success_at.isoformat() if self.last_success_at else None, + "last_failure_at": self.last_failure_at.isoformat() if self.last_failure_at else None, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + if include_secret: - data['secret'] = self.secret - + data["secret"] = self.secret + return data class WebhookDelivery(db.Model): """Track individual webhook delivery attempts""" - - __tablename__ = 'webhook_deliveries' - + + __tablename__ = "webhook_deliveries" + id = db.Column(db.Integer, primary_key=True) - + # Webhook reference - webhook_id = db.Column(db.Integer, db.ForeignKey('webhooks.id', ondelete='CASCADE'), nullable=False, index=True) - webhook = db.relationship('Webhook', backref='deliveries') - + webhook_id = db.Column(db.Integer, db.ForeignKey("webhooks.id", ondelete="CASCADE"), nullable=False, index=True) + webhook = db.relationship("Webhook", backref="deliveries") + # Event information event_type = db.Column(db.String(100), nullable=False, index=True) event_id = db.Column(db.String(100), nullable=True) # Unique ID for this event instance - + # Payload payload = db.Column(db.Text, nullable=False) # JSON-encoded payload payload_hash = db.Column(db.String(64), nullable=True) # SHA256 hash for deduplication - + # Delivery status - status = db.Column(db.String(20), nullable=False, default='pending', index=True) # pending, success, failed, retrying + status = db.Column( + db.String(20), nullable=False, default="pending", index=True + ) # pending, success, failed, retrying attempt_number = db.Column(db.Integer, default=1, nullable=False) - + # HTTP response response_status_code = db.Column(db.Integer, nullable=True) response_body = db.Column(db.Text, nullable=True) response_headers = db.Column(db.JSON, nullable=True) - + # Error information error_message = db.Column(db.Text, nullable=True) error_type = db.Column(db.String(100), nullable=True) # timeout, connection_error, http_error, etc. - + # Timing started_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False) completed_at = db.Column(db.DateTime, nullable=True) duration_ms = db.Column(db.Integer, nullable=True) # Duration in milliseconds - + # Retry information next_retry_at = db.Column(db.DateTime, nullable=True, index=True) retry_count = db.Column(db.Integer, default=0, nullable=False) - + # Indexes __table_args__ = ( - db.Index('ix_webhook_deliveries_webhook_id', 'webhook_id'), - db.Index('ix_webhook_deliveries_status', 'status'), - db.Index('ix_webhook_deliveries_event_type', 'event_type'), - db.Index('ix_webhook_deliveries_next_retry_at', 'next_retry_at'), - db.Index('ix_webhook_deliveries_started_at', 'started_at'), + db.Index("ix_webhook_deliveries_webhook_id", "webhook_id"), + db.Index("ix_webhook_deliveries_status", "status"), + db.Index("ix_webhook_deliveries_event_type", "event_type"), + db.Index("ix_webhook_deliveries_next_retry_at", "next_retry_at"), + db.Index("ix_webhook_deliveries_started_at", "started_at"), ) - + def __repr__(self): - return f'' - + return f"" + @staticmethod def hash_payload(payload): """Generate hash of payload for deduplication""" if isinstance(payload, str): - payload = payload.encode('utf-8') + payload = payload.encode("utf-8") return hashlib.sha256(payload).hexdigest() - + def mark_success(self, status_code, response_body=None, response_headers=None, duration_ms=None): """Mark delivery as successful""" - self.status = 'success' + self.status = "success" self.response_status_code = status_code self.response_body = response_body self.response_headers = response_headers self.completed_at = now_in_app_timezone() if duration_ms is not None: self.duration_ms = duration_ms - + # Update webhook statistics if self.webhook: self.webhook.total_deliveries += 1 self.webhook.successful_deliveries += 1 self.webhook.last_delivery_at = self.completed_at self.webhook.last_success_at = self.completed_at - - def mark_failed(self, error_message, error_type=None, response_status_code=None, response_body=None, duration_ms=None): + + def mark_failed( + self, error_message, error_type=None, response_status_code=None, response_body=None, duration_ms=None + ): """Mark delivery as failed""" - self.status = 'failed' + self.status = "failed" self.error_message = error_message self.error_type = error_type self.response_status_code = response_status_code @@ -260,36 +257,35 @@ def mark_failed(self, error_message, error_type=None, response_status_code=None, self.completed_at = now_in_app_timezone() if duration_ms is not None: self.duration_ms = duration_ms - + # Update webhook statistics if self.webhook: self.webhook.total_deliveries += 1 self.webhook.failed_deliveries += 1 self.webhook.last_delivery_at = self.completed_at self.webhook.last_failure_at = self.completed_at - + def mark_retrying(self, next_retry_at): """Mark delivery as retrying and schedule next attempt""" - self.status = 'retrying' + self.status = "retrying" self.next_retry_at = next_retry_at self.retry_count += 1 - + def to_dict(self): """Convert to dictionary for API responses""" return { - 'id': self.id, - 'webhook_id': self.webhook_id, - 'event_type': self.event_type, - 'event_id': self.event_id, - 'status': self.status, - 'attempt_number': self.attempt_number, - 'response_status_code': self.response_status_code, - 'error_message': self.error_message, - 'error_type': self.error_type, - 'started_at': self.started_at.isoformat() if self.started_at else None, - 'completed_at': self.completed_at.isoformat() if self.completed_at else None, - 'duration_ms': self.duration_ms, - 'retry_count': self.retry_count, - 'next_retry_at': self.next_retry_at.isoformat() if self.next_retry_at else None, + "id": self.id, + "webhook_id": self.webhook_id, + "event_type": self.event_type, + "event_id": self.event_id, + "status": self.status, + "attempt_number": self.attempt_number, + "response_status_code": self.response_status_code, + "error_message": self.error_message, + "error_type": self.error_type, + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "duration_ms": self.duration_ms, + "retry_count": self.retry_count, + "next_retry_at": self.next_retry_at.isoformat() if self.next_retry_at else None, } - diff --git a/app/models/weekly_time_goal.py b/app/models/weekly_time_goal.py index 1258a7d7..630051a3 100644 --- a/app/models/weekly_time_goal.py +++ b/app/models/weekly_time_goal.py @@ -7,8 +7,9 @@ def local_now(): """Get current time in local timezone""" import os import pytz + # Get timezone from environment variable, default to Europe/Rome - timezone_name = os.getenv('TZ', 'Europe/Rome') + timezone_name = os.getenv("TZ", "Europe/Rome") tz = pytz.timezone(timezone_name) now = datetime.now(tz) return now.replace(tzinfo=None) @@ -16,25 +17,25 @@ def local_now(): class WeeklyTimeGoal(db.Model): """Weekly time goal model for tracking user's weekly hour targets""" - - __tablename__ = 'weekly_time_goals' - + + __tablename__ = "weekly_time_goals" + id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True) target_hours = db.Column(db.Float, nullable=False) # Target hours for the week week_start_date = db.Column(db.Date, nullable=False, index=True) # Monday of the week week_end_date = db.Column(db.Date, nullable=False) # Sunday of the week - status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'completed', 'failed', 'cancelled' + status = db.Column(db.String(20), default="active", nullable=False) # 'active', 'completed', 'failed', 'cancelled' notes = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime, default=local_now, nullable=False) updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False) - + # Relationships - user = db.relationship('User', backref=db.backref('weekly_goals', lazy='dynamic', cascade='all, delete-orphan')) - + user = db.relationship("User", backref=db.backref("weekly_goals", lazy="dynamic", cascade="all, delete-orphan")) + def __init__(self, user_id, target_hours, week_start_date=None, notes=None, **kwargs): """Initialize a WeeklyTimeGoal instance. - + Args: user_id: ID of the user who created this goal target_hours: Target hours for the week @@ -44,46 +45,52 @@ def __init__(self, user_id, target_hours, week_start_date=None, notes=None, **kw """ self.user_id = user_id self.target_hours = target_hours - + # If no week_start_date provided, calculate the current week's Monday if week_start_date is None: from app.models.user import User + user = User.query.get(user_id) - week_start_day = user.week_start_day if user else 1 # Default to Monday (user convention: 0=Sunday, 1=Monday) + week_start_day = ( + user.week_start_day if user else 1 + ) # Default to Monday (user convention: 0=Sunday, 1=Monday) today = local_now().date() # Convert user convention (0=Sunday, 1=Monday) to Python weekday (0=Monday, 6=Sunday) python_week_start_day = (week_start_day - 1) % 7 days_since_week_start = (today.weekday() - python_week_start_day) % 7 week_start_date = today - timedelta(days=days_since_week_start) - + self.week_start_date = week_start_date self.week_end_date = week_start_date + timedelta(days=6) self.notes = notes - + # Allow status override from kwargs - if 'status' in kwargs: - self.status = kwargs['status'] - + if "status" in kwargs: + self.status = kwargs["status"] + def __repr__(self): - return f'' - + return f"" + @property def actual_hours(self): """Calculate actual hours worked during this week""" from app.models.time_entry import TimeEntry - + # Query time entries for this user within the week range - total_seconds = db.session.query( - func.sum(TimeEntry.duration_seconds) - ).filter( - TimeEntry.user_id == self.user_id, - TimeEntry.end_time.isnot(None), - func.date(TimeEntry.start_time) >= self.week_start_date, - func.date(TimeEntry.start_time) <= self.week_end_date - ).scalar() or 0 - + total_seconds = ( + db.session.query(func.sum(TimeEntry.duration_seconds)) + .filter( + TimeEntry.user_id == self.user_id, + TimeEntry.end_time.isnot(None), + func.date(TimeEntry.start_time) >= self.week_start_date, + func.date(TimeEntry.start_time) <= self.week_end_date, + ) + .scalar() + or 0 + ) + return round(total_seconds / 3600, 2) - + @property def progress_percentage(self): """Calculate progress as a percentage""" @@ -91,24 +98,24 @@ def progress_percentage(self): return 0 percentage = (self.actual_hours / self.target_hours) * 100 return min(round(percentage, 1), 100) # Cap at 100% - + @property def remaining_hours(self): """Calculate remaining hours to reach the goal""" remaining = self.target_hours - self.actual_hours return max(round(remaining, 2), 0) - + @property def is_completed(self): """Check if the goal has been met""" return self.actual_hours >= self.target_hours - + @property def is_overdue(self): """Check if the week has passed and goal is not completed""" today = local_now().date() return today > self.week_end_date and not self.is_completed - + @property def days_remaining(self): """Calculate days remaining in the week""" @@ -116,91 +123,88 @@ def days_remaining(self): if today > self.week_end_date: return 0 return (self.week_end_date - today).days + 1 - + @property def average_hours_per_day(self): """Calculate average hours needed per day to reach goal""" if self.days_remaining <= 0: return 0 return round(self.remaining_hours / self.days_remaining, 2) - + @property def week_label(self): """Get a human-readable label for the week""" return f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')}" - + def update_status(self): """Update the goal status based on current date and progress""" today = local_now().date() - - if self.status == 'cancelled': + + if self.status == "cancelled": return # Don't auto-update cancelled goals - + if today > self.week_end_date: # Week has ended if self.is_completed: - self.status = 'completed' + self.status = "completed" else: - self.status = 'failed' - elif self.is_completed and self.status == 'active': - self.status = 'completed' - + self.status = "failed" + elif self.is_completed and self.status == "active": + self.status = "completed" + db.session.commit() - + def to_dict(self): """Convert goal to dictionary for API responses""" return { - 'id': self.id, - 'user_id': self.user_id, - 'target_hours': self.target_hours, - 'actual_hours': self.actual_hours, - 'week_start_date': self.week_start_date.isoformat(), - 'week_end_date': self.week_end_date.isoformat(), - 'week_label': self.week_label, - 'status': self.status, - 'notes': self.notes, - 'progress_percentage': self.progress_percentage, - 'remaining_hours': self.remaining_hours, - 'is_completed': self.is_completed, - 'is_overdue': self.is_overdue, - 'days_remaining': self.days_remaining, - 'average_hours_per_day': self.average_hours_per_day, - 'created_at': self.created_at.isoformat() if self.created_at else None, - 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + "id": self.id, + "user_id": self.user_id, + "target_hours": self.target_hours, + "actual_hours": self.actual_hours, + "week_start_date": self.week_start_date.isoformat(), + "week_end_date": self.week_end_date.isoformat(), + "week_label": self.week_label, + "status": self.status, + "notes": self.notes, + "progress_percentage": self.progress_percentage, + "remaining_hours": self.remaining_hours, + "is_completed": self.is_completed, + "is_overdue": self.is_overdue, + "days_remaining": self.days_remaining, + "average_hours_per_day": self.average_hours_per_day, + "created_at": self.created_at.isoformat() if self.created_at else None, + "updated_at": self.updated_at.isoformat() if self.updated_at else None, } - + @staticmethod def get_current_week_goal(user_id): """Get the goal for the current week for a specific user""" from app.models.user import User + user = User.query.get(user_id) week_start_day = user.week_start_day if user else 1 # User convention: 0=Sunday, 1=Monday - + today = local_now().date() # Convert user convention (0=Sunday, 1=Monday) to Python weekday (0=Monday, 6=Sunday) python_week_start_day = (week_start_day - 1) % 7 days_since_week_start = (today.weekday() - python_week_start_day) % 7 week_start = today - timedelta(days=days_since_week_start) week_end = week_start + timedelta(days=6) - + return WeeklyTimeGoal.query.filter( WeeklyTimeGoal.user_id == user_id, WeeklyTimeGoal.week_start_date == week_start, - WeeklyTimeGoal.status != 'cancelled' + WeeklyTimeGoal.status != "cancelled", ).first() - + @staticmethod def get_or_create_current_week(user_id, default_target_hours=40): """Get or create a goal for the current week""" goal = WeeklyTimeGoal.get_current_week_goal(user_id) - + if not goal: - goal = WeeklyTimeGoal( - user_id=user_id, - target_hours=default_target_hours - ) + goal = WeeklyTimeGoal(user_id=user_id, target_hours=default_target_hours) db.session.add(goal) db.session.commit() - - return goal + return goal diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py index b4904dd3..136e5f06 100644 --- a/app/repositories/__init__.py +++ b/app/repositories/__init__.py @@ -15,14 +15,13 @@ from .comment_repository import CommentRepository __all__ = [ - 'TimeEntryRepository', - 'ProjectRepository', - 'InvoiceRepository', - 'UserRepository', - 'ClientRepository', - 'TaskRepository', - 'ExpenseRepository', - 'PaymentRepository', - 'CommentRepository', + "TimeEntryRepository", + "ProjectRepository", + "InvoiceRepository", + "UserRepository", + "ClientRepository", + "TaskRepository", + "ExpenseRepository", + "PaymentRepository", + "CommentRepository", ] - diff --git a/app/repositories/base_repository.py b/app/repositories/base_repository.py index c2ac0644..b5234277 100644 --- a/app/repositories/base_repository.py +++ b/app/repositories/base_repository.py @@ -17,54 +17,54 @@ def get_active_projects(self): from sqlalchemy.orm import Query from app import db -ModelType = TypeVar('ModelType') +ModelType = TypeVar("ModelType") class BaseRepository(Generic[ModelType]): """ Base repository with common CRUD operations. - + Provides standard database operations that can be used by all repositories. Subclasses should add domain-specific query methods. - + Args: model: SQLAlchemy model class - + Example: repo = BaseRepository(Project) project = repo.get_by_id(1) projects = repo.find_by(status='active') """ - + def __init__(self, model: type[ModelType]): """ Initialize repository with a model class. - + Args: model: SQLAlchemy model class """ self.model = model - + def get_by_id(self, id: int) -> Optional[ModelType]: """ Get a single record by ID. - + Args: id: Record ID - + Returns: Model instance or None if not found """ return self.model.query.get(id) - + def get_all(self, limit: Optional[int] = None, offset: int = 0) -> List[ModelType]: """ Get all records with optional pagination. - + Args: limit: Maximum number of records to return offset: Number of records to skip - + Returns: List of model instances """ @@ -72,53 +72,53 @@ def get_all(self, limit: Optional[int] = None, offset: int = 0) -> List[ModelTyp if limit: query = query.limit(limit).offset(offset) return query.all() - + def find_by(self, **kwargs) -> List[ModelType]: """ Find records by field values. - + Args: **kwargs: Field name-value pairs to filter by - + Returns: List of matching model instances """ return self.model.query.filter_by(**kwargs).all() - + def find_one_by(self, **kwargs) -> Optional[ModelType]: """ Find a single record by field values. - + Args: **kwargs: Field name-value pairs to filter by - + Returns: First matching model instance or None """ return self.model.query.filter_by(**kwargs).first() - + def create(self, **kwargs) -> ModelType: """ Create a new record. - + Args: **kwargs: Field name-value pairs for the new record - + Returns: Created model instance (not yet committed) """ instance = self.model(**kwargs) db.session.add(instance) return instance - + def update(self, instance: ModelType, **kwargs) -> ModelType: """ Update an existing record. - + Args: instance: Model instance to update **kwargs: Field name-value pairs to update - + Returns: Updated model instance """ @@ -126,14 +126,14 @@ def update(self, instance: ModelType, **kwargs) -> ModelType: if hasattr(instance, key): setattr(instance, key, value) return instance - + def delete(self, instance: ModelType) -> bool: """ Delete a record. - + Args: instance: Model instance to delete - + Returns: True if successful, False otherwise """ @@ -142,14 +142,14 @@ def delete(self, instance: ModelType) -> bool: return True except Exception: return False - + def count(self, **kwargs) -> int: """ Count records matching criteria. - + Args: **kwargs: Field name-value pairs to filter by - + Returns: Number of matching records """ @@ -157,25 +157,24 @@ def count(self, **kwargs) -> int: if kwargs: query = query.filter_by(**kwargs) return query.count() - + def exists(self, **kwargs) -> bool: """ Check if a record exists. - + Args: **kwargs: Field name-value pairs to filter by - + Returns: True if at least one matching record exists """ return self.model.query.filter_by(**kwargs).first() is not None - + def query(self) -> Query: """ Get a query object for custom queries. - + Returns: SQLAlchemy Query object for the model """ return self.model.query - diff --git a/app/repositories/client_repository.py b/app/repositories/client_repository.py index 0ae805fc..4726e5a5 100644 --- a/app/repositories/client_repository.py +++ b/app/repositories/client_repository.py @@ -11,21 +11,18 @@ class ClientRepository(BaseRepository[Client]): """Repository for client operations""" - + def __init__(self): super().__init__(Client) - + def get_with_projects(self, client_id: int) -> Optional[Client]: """Get client with projects loaded""" - return self.model.query.options( - joinedload(Client.projects) - ).get(client_id) - + return self.model.query.options(joinedload(Client.projects)).get(client_id) + def get_active_clients(self) -> List[Client]: """Get all active clients""" - return self.model.query.filter_by(status='active').order_by(Client.name).all() - + return self.model.query.filter_by(status="active").order_by(Client.name).all() + def get_by_name(self, name: str) -> Optional[Client]: """Get client by name""" return self.model.query.filter_by(name=name).first() - diff --git a/app/repositories/comment_repository.py b/app/repositories/comment_repository.py index 8e7196c1..84b3a8d9 100644 --- a/app/repositories/comment_repository.py +++ b/app/repositories/comment_repository.py @@ -11,84 +11,62 @@ class CommentRepository(BaseRepository[Comment]): """Repository for comment operations""" - + def __init__(self): super().__init__(Comment) - + def get_by_project( - self, - project_id: int, - include_replies: bool = True, - include_relations: bool = False + self, project_id: int, include_replies: bool = True, include_relations: bool = False ) -> List[Comment]: """Get comments for a project""" query = self.model.query.filter_by(project_id=project_id) - + if not include_replies: query = query.filter_by(parent_id=None) - + if include_relations: - query = query.options( - joinedload(Comment.author), - joinedload(Comment.replies) if include_replies else query - ) - + query = query.options(joinedload(Comment.author), joinedload(Comment.replies) if include_replies else query) + return query.order_by(Comment.created_at.asc()).all() - - def get_by_task( - self, - task_id: int, - include_replies: bool = True, - include_relations: bool = False - ) -> List[Comment]: + + def get_by_task(self, task_id: int, include_replies: bool = True, include_relations: bool = False) -> List[Comment]: """Get comments for a task""" query = self.model.query.filter_by(task_id=task_id) - + if not include_replies: query = query.filter_by(parent_id=None) - + if include_relations: - query = query.options( - joinedload(Comment.author), - joinedload(Comment.replies) if include_replies else query - ) - + query = query.options(joinedload(Comment.author), joinedload(Comment.replies) if include_replies else query) + return query.order_by(Comment.created_at.asc()).all() - + def get_by_quote( self, quote_id: int, include_replies: bool = True, include_internal: bool = True, - include_relations: bool = False + include_relations: bool = False, ) -> List[Comment]: """Get comments for a quote""" query = self.model.query.filter_by(quote_id=quote_id) - + if not include_internal: query = query.filter_by(is_internal=False) - + if not include_replies: query = query.filter_by(parent_id=None) - + if include_relations: - query = query.options( - joinedload(Comment.author), - joinedload(Comment.replies) if include_replies else query - ) - + query = query.options(joinedload(Comment.author), joinedload(Comment.replies) if include_replies else query) + return query.order_by(Comment.created_at.asc()).all() - - def get_replies( - self, - parent_id: int, - include_relations: bool = False - ) -> List[Comment]: + + def get_replies(self, parent_id: int, include_relations: bool = False) -> List[Comment]: """Get replies to a comment""" query = self.model.query.filter_by(parent_id=parent_id) - + if include_relations: query = query.options(joinedload(Comment.author)) - - return query.order_by(Comment.created_at.asc()).all() + return query.order_by(Comment.created_at.asc()).all() diff --git a/app/repositories/expense_repository.py b/app/repositories/expense_repository.py index 5c05b2ee..ce61946d 100644 --- a/app/repositories/expense_repository.py +++ b/app/repositories/expense_repository.py @@ -12,78 +12,73 @@ class ExpenseRepository(BaseRepository[Expense]): """Repository for expense operations""" - + def __init__(self): super().__init__(Expense) - + def get_by_project( self, project_id: int, start_date: Optional[date] = None, end_date: Optional[date] = None, - include_relations: bool = False + include_relations: bool = False, ) -> List[Expense]: """Get expenses for a project""" query = self.model.query.filter_by(project_id=project_id) - + if start_date: - query = query.filter(Expense.date >= start_date) - + query = query.filter(Expense.expense_date >= start_date) + if end_date: - query = query.filter(Expense.date <= end_date) - + query = query.filter(Expense.expense_date <= end_date) + if include_relations: query = query.options( - joinedload(Expense.project), - joinedload(Expense.category) if hasattr(Expense, 'category') else query + joinedload(Expense.project), joinedload(Expense.category) if hasattr(Expense, "category") else query ) - - return query.order_by(Expense.date.desc()).all() - + + return query.order_by(Expense.expense_date.desc()).all() + def get_billable( - self, - project_id: Optional[int] = None, - start_date: Optional[date] = None, - end_date: Optional[date] = None + self, project_id: Optional[int] = None, start_date: Optional[date] = None, end_date: Optional[date] = None ) -> List[Expense]: """Get billable expenses""" query = self.model.query.filter_by(billable=True) - + if project_id: query = query.filter_by(project_id=project_id) - + if start_date: - query = query.filter(Expense.date >= start_date) - + query = query.filter(Expense.expense_date >= start_date) + if end_date: - query = query.filter(Expense.date <= end_date) - - return query.order_by(Expense.date.desc()).all() - + query = query.filter(Expense.expense_date <= end_date) + + return query.order_by(Expense.expense_date.desc()).all() + def get_total_amount( self, project_id: Optional[int] = None, start_date: Optional[date] = None, end_date: Optional[date] = None, - billable_only: bool = False + billable_only: bool = False, ) -> float: """Get total expense amount""" from sqlalchemy import func - + query = db.session.query(func.sum(Expense.amount)) - + if project_id: query = query.filter_by(project_id=project_id) - + if start_date: - query = query.filter(Expense.date >= start_date) - + query = query.filter(Expense.expense_date >= start_date) + if end_date: - query = query.filter(Expense.date <= end_date) - + query = query.filter(Expense.expense_date <= end_date) + if billable_only: query = query.filter_by(billable=True) - + result = query.scalar() return float(result) if result else 0.0 - diff --git a/app/repositories/invoice_repository.py b/app/repositories/invoice_repository.py index ce019b3a..30eb275f 100644 --- a/app/repositories/invoice_repository.py +++ b/app/repositories/invoice_repository.py @@ -13,109 +13,84 @@ class InvoiceRepository(BaseRepository[Invoice]): """Repository for invoice operations""" - + def __init__(self): super().__init__(Invoice) - - def get_by_project( - self, - project_id: int, - include_relations: bool = False - ) -> List[Invoice]: + + def get_by_project(self, project_id: int, include_relations: bool = False) -> List[Invoice]: """Get invoices for a project""" query = self.model.query.filter_by(project_id=project_id) - + if include_relations: - query = query.options( - joinedload(Invoice.project), - joinedload(Invoice.client) - ) - + query = query.options(joinedload(Invoice.project), joinedload(Invoice.client)) + return query.order_by(Invoice.issue_date.desc()).all() - + def get_by_client( - self, - client_id: int, - status: Optional[str] = None, - include_relations: bool = False + self, client_id: int, status: Optional[str] = None, include_relations: bool = False ) -> List[Invoice]: """Get invoices for a client""" query = self.model.query.filter_by(client_id=client_id) - + if status: query = query.filter_by(status=status) - + if include_relations: - query = query.options( - joinedload(Invoice.project), - joinedload(Invoice.client) - ) - + query = query.options(joinedload(Invoice.project), joinedload(Invoice.client)) + return query.order_by(Invoice.issue_date.desc()).all() - - def get_by_status( - self, - status: str, - include_relations: bool = False - ) -> List[Invoice]: + + def get_by_status(self, status: str, include_relations: bool = False) -> List[Invoice]: """Get invoices by status""" query = self.model.query.filter_by(status=status) - + if include_relations: - query = query.options( - joinedload(Invoice.project), - joinedload(Invoice.client) - ) - + query = query.options(joinedload(Invoice.project), joinedload(Invoice.client)) + return query.order_by(Invoice.issue_date.desc()).all() - + def get_overdue(self, include_relations: bool = False) -> List[Invoice]: """Get overdue invoices""" today = date.today() query = self.model.query.filter( - Invoice.due_date < today, - Invoice.status.in_([InvoiceStatus.SENT.value, InvoiceStatus.PARTIALLY_PAID.value]) + Invoice.due_date < today, Invoice.status.in_([InvoiceStatus.SENT.value, InvoiceStatus.PARTIALLY_PAID.value]) ) - + if include_relations: - query = query.options( - joinedload(Invoice.project), - joinedload(Invoice.client) - ) - + query = query.options(joinedload(Invoice.project), joinedload(Invoice.client)) + return query.order_by(Invoice.due_date).all() - + def get_with_relations(self, invoice_id: int) -> Optional[Invoice]: """Get invoice with all relations loaded""" - return self.model.query.options( - joinedload(Invoice.project), - joinedload(Invoice.client) - ).get(invoice_id) - + return self.model.query.options(joinedload(Invoice.project), joinedload(Invoice.client)).get(invoice_id) + def generate_invoice_number(self) -> str: """Generate a unique invoice number""" from datetime import datetime - + # Format: INV-YYYYMMDD-XXXX - today = datetime.now().strftime('%Y%m%d') + today = datetime.now().strftime("%Y%m%d") prefix = f"INV-{today}-" - + # Find the highest number for today - last_invoice = self.model.query.filter( - Invoice.invoice_number.like(f"{prefix}%") - ).order_by(Invoice.invoice_number.desc()).first() - + last_invoice = ( + self.model.query.filter(Invoice.invoice_number.like(f"{prefix}%")) + .order_by(Invoice.invoice_number.desc()) + .first() + ) + if last_invoice: try: - last_num = int(last_invoice.invoice_number.split('-')[-1]) + last_num = int(last_invoice.invoice_number.split("-")[-1]) next_num = last_num + 1 except (ValueError, IndexError): next_num = 1 else: next_num = 1 - + return f"{prefix}{next_num:04d}" - + def mark_as_sent(self, invoice_id: int) -> Optional[Invoice]: """Mark an invoice as sent""" invoice = self.get_by_id(invoice_id) @@ -123,13 +98,13 @@ def mark_as_sent(self, invoice_id: int) -> Optional[Invoice]: invoice.status = InvoiceStatus.SENT.value return invoice return None - + def mark_as_paid( self, invoice_id: int, payment_date: Optional[date] = None, payment_method: Optional[str] = None, - payment_reference: Optional[str] = None + payment_reference: Optional[str] = None, ) -> Optional[Invoice]: """Mark an invoice as paid""" invoice = self.get_by_id(invoice_id) @@ -142,4 +117,3 @@ def mark_as_paid( invoice.amount_paid = invoice.total_amount return invoice return None - diff --git a/app/repositories/payment_repository.py b/app/repositories/payment_repository.py index 115fb80c..9e1cd4b8 100644 --- a/app/repositories/payment_repository.py +++ b/app/repositories/payment_repository.py @@ -14,82 +14,64 @@ class PaymentRepository(BaseRepository[Payment]): """Repository for payment operations""" - + def __init__(self): super().__init__(Payment) - - def get_by_invoice( - self, - invoice_id: int, - include_relations: bool = False - ) -> List[Payment]: + + def get_by_invoice(self, invoice_id: int, include_relations: bool = False) -> List[Payment]: """Get payments for an invoice""" query = self.model.query.filter_by(invoice_id=invoice_id) - + if include_relations: query = query.options(joinedload(Payment.receiver)) - + return query.order_by(Payment.payment_date.desc()).all() - - def get_by_date_range( - self, - start_date: date, - end_date: date, - include_relations: bool = False - ) -> List[Payment]: + + def get_by_date_range(self, start_date: date, end_date: date, include_relations: bool = False) -> List[Payment]: """Get payments within a date range""" - query = self.model.query.filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date - ) - + query = self.model.query.filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date) + if include_relations: query = query.options( - joinedload(Payment.receiver), - joinedload(Payment.invoice) if hasattr(Payment, 'invoice') else query + joinedload(Payment.receiver), joinedload(Payment.invoice) if hasattr(Payment, "invoice") else query ) - + return query.order_by(Payment.payment_date.desc()).all() - - def get_by_status( - self, - status: str, - include_relations: bool = False - ) -> List[Payment]: + + def get_by_status(self, status: str, include_relations: bool = False) -> List[Payment]: """Get payments by status""" query = self.model.query.filter_by(status=status) - + if include_relations: query = query.options(joinedload(Payment.receiver)) - + return query.order_by(Payment.payment_date.desc()).all() - + def get_total_amount( self, invoice_id: Optional[int] = None, start_date: Optional[date] = None, end_date: Optional[date] = None, - status: Optional[str] = None + status: Optional[str] = None, ) -> Decimal: """Get total payment amount""" query = db.session.query(func.sum(Payment.amount)) - + if invoice_id: query = query.filter_by(invoice_id=invoice_id) - + if start_date: query = query.filter(Payment.payment_date >= start_date) - + if end_date: query = query.filter(Payment.payment_date <= end_date) - + if status: query = query.filter_by(status=status) - + result = query.scalar() - return Decimal(result) if result else Decimal('0.00') - + return Decimal(result) if result else Decimal("0.00") + def get_total_for_invoice(self, invoice_id: int) -> Decimal: """Get total payments for an invoice""" - return self.get_total_amount(invoice_id=invoice_id, status='completed') - + return self.get_total_amount(invoice_id=invoice_id, status="completed") diff --git a/app/repositories/project_repository.py b/app/repositories/project_repository.py index bc909ebc..8c0fa75d 100644 --- a/app/repositories/project_repository.py +++ b/app/repositories/project_repository.py @@ -12,64 +12,53 @@ class ProjectRepository(BaseRepository[Project]): """Repository for project operations""" - + def __init__(self): super().__init__(Project) - + def get_active_projects( - self, - user_id: Optional[int] = None, - client_id: Optional[int] = None, - include_relations: bool = False + self, user_id: Optional[int] = None, client_id: Optional[int] = None, include_relations: bool = False ) -> List[Project]: """Get active projects with optional filters""" query = self.model.query.filter_by(status=ProjectStatus.ACTIVE.value) - + if client_id: query = query.filter_by(client_id=client_id) - + if include_relations: # Only eagerly load client (time_entries is dynamic, can't be eagerly loaded) query = query.options(joinedload(Project.client_obj)) - + # If user_id provided, filter projects user has access to # (This would need permission logic in a real implementation) - + return query.order_by(Project.name).all() - + def get_by_client( - self, - client_id: int, - status: Optional[str] = None, - include_relations: bool = False + self, client_id: int, status: Optional[str] = None, include_relations: bool = False ) -> List[Project]: """Get projects for a client""" query = self.model.query.filter_by(client_id=client_id) - + if status: query = query.filter_by(status=status) - + if include_relations: query = query.options(joinedload(Project.client_obj)) - + return query.order_by(Project.name).all() - - def get_with_stats( - self, - project_id: int - ) -> Optional[Project]: + + def get_with_stats(self, project_id: int) -> Optional[Project]: """Get project with related statistics (time entries, costs, etc.)""" # Note: time_entries, tasks, and costs are dynamic relationships (lazy='dynamic'), # so they cannot be eagerly loaded with joinedload(). They return query objects # that can be filtered and accessed when needed. - return self.model.query.options( - joinedload(Project.client_obj) - ).get(project_id) - + return self.model.query.options(joinedload(Project.client_obj)).get(project_id) + def archive(self, project_id: int, archived_by: int, reason: Optional[str] = None) -> Optional[Project]: """Archive a project""" from datetime import datetime - + project = self.get_by_id(project_id) if project: project.status = ProjectStatus.ARCHIVED.value @@ -78,7 +67,7 @@ def archive(self, project_id: int, archived_by: int, reason: Optional[str] = Non project.archived_reason = reason return project return None - + def unarchive(self, project_id: int) -> Optional[Project]: """Unarchive a project""" project = self.get_by_id(project_id) @@ -89,16 +78,12 @@ def unarchive(self, project_id: int) -> Optional[Project]: project.archived_reason = None return project return None - + def get_billable_projects(self, client_id: Optional[int] = None) -> List[Project]: """Get billable projects""" - query = self.model.query.filter_by( - billable=True, - status=ProjectStatus.ACTIVE.value - ) - + query = self.model.query.filter_by(billable=True, status=ProjectStatus.ACTIVE.value) + if client_id: query = query.filter_by(client_id=client_id) - - return query.order_by(Project.name).all() + return query.order_by(Project.name).all() diff --git a/app/repositories/task_repository.py b/app/repositories/task_repository.py index cd25572d..d3623b22 100644 --- a/app/repositories/task_repository.py +++ b/app/repositories/task_repository.py @@ -12,77 +12,62 @@ class TaskRepository(BaseRepository[Task]): """Repository for task operations""" - + def __init__(self): super().__init__(Task) - + def get_by_project( - self, - project_id: int, - status: Optional[str] = None, - include_relations: bool = False + self, project_id: int, status: Optional[str] = None, include_relations: bool = False ) -> List[Task]: """Get tasks for a project""" query = self.model.query.filter_by(project_id=project_id) - + if status: query = query.filter_by(status=status) - + if include_relations: - query = query.options( - joinedload(Task.project), - joinedload(Task.assigned_user), - joinedload(Task.creator) - ) - + query = query.options(joinedload(Task.project), joinedload(Task.assigned_user), joinedload(Task.creator)) + return query.order_by(Task.priority.desc(), Task.due_date.asc()).all() - + def get_by_assignee( - self, - assignee_id: int, - status: Optional[str] = None, - include_relations: bool = False + self, assignee_id: int, status: Optional[str] = None, include_relations: bool = False ) -> List[Task]: """Get tasks assigned to a user""" query = self.model.query.filter_by(assignee_id=assignee_id) - + if status: query = query.filter_by(status=status) - + if include_relations: query = query.options(joinedload(Task.project)) - + return query.order_by(Task.priority.desc(), Task.due_date.asc()).all() - + def get_by_status( - self, - status: str, - project_id: Optional[int] = None, - include_relations: bool = False + self, status: str, project_id: Optional[int] = None, include_relations: bool = False ) -> List[Task]: """Get tasks by status""" query = self.model.query.filter_by(status=status) - + if project_id: query = query.filter_by(project_id=project_id) - + if include_relations: query = query.options(joinedload(Task.project)) - + return query.order_by(Task.priority.desc(), Task.due_date.asc()).all() - + def get_overdue(self, include_relations: bool = False) -> List[Task]: """Get overdue tasks""" from datetime import date - + today = date.today() query = self.model.query.filter( - Task.due_date < today, - Task.status.notin_([TaskStatus.DONE.value, TaskStatus.CANCELLED.value]) + Task.due_date < today, Task.status.notin_([TaskStatus.DONE.value, TaskStatus.CANCELLED.value]) ) - + if include_relations: query = query.options(joinedload(Task.project)) - - return query.order_by(Task.due_date.asc()).all() + return query.order_by(Task.due_date.asc()).all() diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index 69ecb045..9eedbc36 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -14,119 +14,92 @@ class TimeEntryRepository(BaseRepository[TimeEntry]): """Repository for time entry operations""" - + def __init__(self): super().__init__(TimeEntry) - + def get_active_timer(self, user_id: int) -> Optional[TimeEntry]: """Get the active timer for a user""" - return self.model.query.filter_by( - user_id=user_id, - end_time=None - ).first() - + return self.model.query.filter_by(user_id=user_id, end_time=None).first() + def get_by_user( - self, - user_id: int, - limit: Optional[int] = None, - offset: int = 0, - include_relations: bool = False + self, user_id: int, limit: Optional[int] = None, offset: int = 0, include_relations: bool = False ) -> List[TimeEntry]: """Get time entries for a user with optional relations""" query = self.model.query.filter_by(user_id=user_id) - + if include_relations: - query = query.options( - joinedload(TimeEntry.project), - joinedload(TimeEntry.task), - joinedload(TimeEntry.user) - ) - + query = query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.task), joinedload(TimeEntry.user)) + query = query.order_by(TimeEntry.start_time.desc()) - + if limit: query = query.limit(limit).offset(offset) - + return query.all() - + def get_by_project( - self, - project_id: int, - limit: Optional[int] = None, - offset: int = 0, - include_relations: bool = False + self, project_id: int, limit: Optional[int] = None, offset: int = 0, include_relations: bool = False ) -> List[TimeEntry]: """Get time entries for a project""" query = self.model.query.filter_by(project_id=project_id) - + if include_relations: - query = query.options( - joinedload(TimeEntry.user), - joinedload(TimeEntry.task) - ) - + query = query.options(joinedload(TimeEntry.user), joinedload(TimeEntry.task)) + query = query.order_by(TimeEntry.start_time.desc()) - + if limit: query = query.limit(limit).offset(offset) - + return query.all() - + def get_by_date_range( self, start_date: datetime, end_date: datetime, user_id: Optional[int] = None, project_id: Optional[int] = None, - include_relations: bool = False + include_relations: bool = False, ) -> List[TimeEntry]: """Get time entries within a date range""" - query = self.model.query.filter( - and_( - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date - ) - ) - + query = self.model.query.filter(and_(TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date)) + if user_id: query = query.filter_by(user_id=user_id) - + if project_id: query = query.filter_by(project_id=project_id) - + if include_relations: - query = query.options( - joinedload(TimeEntry.user), - joinedload(TimeEntry.project), - joinedload(TimeEntry.task) - ) - + query = query.options(joinedload(TimeEntry.user), joinedload(TimeEntry.project), joinedload(TimeEntry.task)) + return query.order_by(TimeEntry.start_time.desc()).all() - + def get_billable_entries( self, user_id: Optional[int] = None, project_id: Optional[int] = None, start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + end_date: Optional[datetime] = None, ) -> List[TimeEntry]: """Get billable time entries with optional filters""" query = self.model.query.filter_by(billable=True) - + if user_id: query = query.filter_by(user_id=user_id) - + if project_id: query = query.filter_by(project_id=project_id) - + if start_date: query = query.filter(TimeEntry.start_time >= start_date) - + if end_date: query = query.filter(TimeEntry.start_time <= end_date) - + return query.order_by(TimeEntry.start_time.desc()).all() - + def stop_timer(self, entry_id: int, end_time: datetime) -> Optional[TimeEntry]: """Stop an active timer""" entry = self.get_by_id(entry_id) @@ -135,29 +108,24 @@ def stop_timer(self, entry_id: int, end_time: datetime) -> Optional[TimeEntry]: entry.calculate_duration() return entry return None - + def create_timer( self, user_id: int, project_id: int, task_id: Optional[int] = None, notes: Optional[str] = None, - source: str = TimeEntrySource.AUTO.value + source: str = TimeEntrySource.AUTO.value, ) -> TimeEntry: """Create a new timer (active time entry)""" from app.models.time_entry import local_now - + entry = self.model( - user_id=user_id, - project_id=project_id, - task_id=task_id, - start_time=local_now(), - notes=notes, - source=source + user_id=user_id, project_id=project_id, task_id=task_id, start_time=local_now(), notes=notes, source=source ) db.session.add(entry) return entry - + def create_manual_entry( self, user_id: int, @@ -167,7 +135,7 @@ def create_manual_entry( task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, - billable: bool = True + billable: bool = True, ) -> TimeEntry: """Create a manual time entry""" entry = self.model( @@ -179,40 +147,39 @@ def create_manual_entry( notes=notes, tags=tags, billable=billable, - source=TimeEntrySource.MANUAL.value + source=TimeEntrySource.MANUAL.value, ) entry.calculate_duration() db.session.add(entry) return entry - + def get_total_duration( self, user_id: Optional[int] = None, project_id: Optional[int] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, - billable_only: bool = False + billable_only: bool = False, ) -> int: """Get total duration in seconds for matching entries""" from sqlalchemy import func - + query = db.session.query(func.sum(TimeEntry.duration_seconds)) - + if user_id: query = query.filter_by(user_id=user_id) - + if project_id: query = query.filter_by(project_id=project_id) - + if start_date: query = query.filter(TimeEntry.start_time >= start_date) - + if end_date: query = query.filter(TimeEntry.start_time <= end_date) - + if billable_only: query = query.filter_by(billable=True) - + result = query.scalar() return int(result) if result else 0 - diff --git a/app/repositories/user_repository.py b/app/repositories/user_repository.py index 540598c0..25f48b40 100644 --- a/app/repositories/user_repository.py +++ b/app/repositories/user_repository.py @@ -11,26 +11,22 @@ class UserRepository(BaseRepository[User]): """Repository for user operations""" - + def __init__(self): super().__init__(User) - + def get_by_username(self, username: str) -> Optional[User]: """Get user by username""" return self.model.query.filter_by(username=username).first() - + def get_by_role(self, role: str) -> List[User]: """Get users by role""" return self.model.query.filter_by(role=role).all() - + def get_active_users(self) -> List[User]: """Get all active users""" return self.model.query.filter_by(is_active=True).all() - + def get_admins(self) -> List[User]: """Get all admin users""" - return self.model.query.filter_by( - role=UserRole.ADMIN.value, - is_active=True - ).all() - + return self.model.query.filter_by(role=UserRole.ADMIN.value, is_active=True).all() diff --git a/app/routes/admin.py b/app/routes/admin.py index a0d88919..e025bb13 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,4 +1,16 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file, jsonify, render_template_string +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + flash, + current_app, + send_from_directory, + send_file, + jsonify, + render_template_string, +) from flask_babel import gettext as _ from flask_login import login_required, current_user import app as app_module @@ -19,109 +31,115 @@ import time import shutil -admin_bp = Blueprint('admin', __name__) +admin_bp = Blueprint("admin", __name__) # In-memory restore progress tracking (simple, per-process) RESTORE_PROGRESS = {} # Allowed file extensions for logos # Avoid SVG due to XSS risk unless sanitized server-side -ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +ALLOWED_LOGO_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} + def admin_required(f): """Decorator to require admin access - + DEPRECATED: Use @admin_or_permission_required() with specific permissions instead. This decorator is kept for backward compatibility. """ from functools import wraps + @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or not current_user.is_admin: - flash(_('Administrator access required'), 'error') - return redirect(url_for('main.dashboard')) + flash(_("Administrator access required"), "error") + return redirect(url_for("main.dashboard")) return f(*args, **kwargs) + return decorated_function + def allowed_logo_file(filename): """Check if the uploaded file has an allowed extension""" - return '.' in filename and \ - filename.rsplit('.', 1)[1].lower() in ALLOWED_LOGO_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_LOGO_EXTENSIONS + def get_upload_folder(): """Get the upload folder path for logos""" - upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'logos') + upload_folder = os.path.join(current_app.root_path, "static", "uploads", "logos") try: os.makedirs(upload_folder, exist_ok=True) - current_app.logger.info(f'Logo upload folder ensured: {upload_folder}') + current_app.logger.info(f"Logo upload folder ensured: {upload_folder}") except Exception as e: - current_app.logger.error(f'Error creating upload folder {upload_folder}: {str(e)}') + current_app.logger.error(f"Error creating upload folder {upload_folder}: {str(e)}") raise return upload_folder -@admin_bp.route('/admin') + +@admin_bp.route("/admin") @login_required @admin_required def admin_dashboard(): """Admin dashboard""" from app.config import Config - + # Get system statistics total_users = User.query.count() active_users = User.query.filter_by(is_active=True).count() total_projects = Project.query.count() - active_projects = Project.query.filter_by(status='active').count() + active_projects = Project.query.filter_by(status="active").count() total_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count() active_timers = TimeEntry.query.filter_by(end_time=None).count() - + # Get recent activity - recent_entries = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None) - ).order_by( - TimeEntry.created_at.desc() - ).limit(10).all() - + recent_entries = ( + TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).order_by(TimeEntry.created_at.desc()).limit(10).all() + ) + # Get OIDC status - auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower() - oidc_enabled = auth_method in ('oidc', 'both') - oidc_issuer = getattr(Config, 'OIDC_ISSUER', None) - oidc_configured = oidc_enabled and oidc_issuer and getattr(Config, 'OIDC_CLIENT_ID', None) and getattr(Config, 'OIDC_CLIENT_SECRET', None) - + auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() + oidc_enabled = auth_method in ("oidc", "both") + oidc_issuer = getattr(Config, "OIDC_ISSUER", None) + oidc_configured = ( + oidc_enabled + and oidc_issuer + and getattr(Config, "OIDC_CLIENT_ID", None) + and getattr(Config, "OIDC_CLIENT_SECRET", None) + ) + # Count OIDC users oidc_users_count = 0 try: - oidc_users_count = User.query.filter( - User.oidc_issuer.isnot(None), - User.oidc_sub.isnot(None) - ).count() + oidc_users_count = User.query.filter(User.oidc_issuer.isnot(None), User.oidc_sub.isnot(None)).count() except Exception: pass - + # Build stats object expected by the template stats = { - 'total_users': total_users, - 'active_users': active_users, - 'total_projects': total_projects, - 'active_projects': active_projects, - 'total_entries': total_entries, - 'total_hours': TimeEntry.get_total_hours_for_period(), - 'billable_hours': TimeEntry.get_total_hours_for_period(billable_only=True), - 'last_backup': None + "total_users": total_users, + "active_users": active_users, + "total_projects": total_projects, + "active_projects": active_projects, + "total_entries": total_entries, + "total_hours": TimeEntry.get_total_hours_for_period(), + "billable_hours": TimeEntry.get_total_hours_for_period(billable_only=True), + "last_backup": None, } - + return render_template( - 'admin/dashboard.html', + "admin/dashboard.html", stats=stats, active_timers=active_timers, recent_entries=recent_entries, oidc_enabled=oidc_enabled, oidc_configured=oidc_configured, oidc_auth_method=auth_method, - oidc_users_count=oidc_users_count + oidc_users_count=oidc_users_count, ) + # Compatibility alias for code/templates that might reference 'admin.dashboard' -@admin_bp.route('/admin/dashboard') +@admin_bp.route("/admin/dashboard") @login_required @admin_required def admin_dashboard_alias(): @@ -130,696 +148,746 @@ def admin_dashboard_alias(): Some older references may use the endpoint name 'admin.dashboard'. Redirect to the canonical admin dashboard endpoint. """ - return redirect(url_for('admin.admin_dashboard')) + return redirect(url_for("admin.admin_dashboard")) + -@admin_bp.route('/admin/users') +@admin_bp.route("/admin/users") @login_required -@admin_or_permission_required('view_users') +@admin_or_permission_required("view_users") def list_users(): """List all users""" users = User.query.order_by(User.username).all() - + # Build stats for users page stats = { - 'total_users': User.query.count(), - 'active_users': User.query.filter_by(is_active=True).count(), - 'admin_users': User.query.filter_by(role='admin').count(), - 'total_hours': TimeEntry.get_total_hours_for_period() + "total_users": User.query.count(), + "active_users": User.query.filter_by(is_active=True).count(), + "admin_users": User.query.filter_by(role="admin").count(), + "total_hours": TimeEntry.get_total_hours_for_period(), } - - return render_template('admin/users.html', users=users, stats=stats) -@admin_bp.route('/admin/users/create', methods=['GET', 'POST']) + return render_template("admin/users.html", users=users, stats=stats) + + +@admin_bp.route("/admin/users/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_users') +@admin_or_permission_required("create_users") def create_user(): """Create a new user""" - if request.method == 'POST': - username = request.form.get('username', '').strip().lower() - role = request.form.get('role', 'user') - + if request.method == "POST": + username = request.form.get("username", "").strip().lower() + role = request.form.get("role", "user") + if not username: - flash(_('Username is required'), 'error') - return render_template('admin/user_form.html', user=None) - + flash(_("Username is required"), "error") + return render_template("admin/user_form.html", user=None) + # Check if user already exists if User.query.filter_by(username=username).first(): - flash(_('User already exists'), 'error') - return render_template('admin/user_form.html', user=None) - + flash(_("User already exists"), "error") + return render_template("admin/user_form.html", user=None) + # Create user user = User(username=username, role=role) db.session.add(user) - if not safe_commit('admin_create_user', {'username': username}): - flash(_('Could not create user due to a database error. Please check server logs.'), 'error') - return render_template('admin/user_form.html', user=None) - - flash(_('User "%(username)s" created successfully', username=username), 'success') - return redirect(url_for('admin.list_users')) - - return render_template('admin/user_form.html', user=None) - -@admin_bp.route('/admin/users//edit', methods=['GET', 'POST']) + if not safe_commit("admin_create_user", {"username": username}): + flash(_("Could not create user due to a database error. Please check server logs."), "error") + return render_template("admin/user_form.html", user=None) + + flash(_('User "%(username)s" created successfully', username=username), "success") + return redirect(url_for("admin.list_users")) + + return render_template("admin/user_form.html", user=None) + + +@admin_bp.route("/admin/users//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('edit_users') +@admin_or_permission_required("edit_users") def edit_user(user_id): """Edit an existing user""" from app.models import Client + user = User.query.get_or_404(user_id) - clients = Client.query.filter_by(status='active').order_by(Client.name).all() - - if request.method == 'POST': - username = request.form.get('username', '').strip().lower() - role = request.form.get('role', 'user') - is_active = request.form.get('is_active') == 'on' - client_portal_enabled = request.form.get('client_portal_enabled') == 'on' - client_id = request.form.get('client_id', '').strip() - + clients = Client.query.filter_by(status="active").order_by(Client.name).all() + + if request.method == "POST": + username = request.form.get("username", "").strip().lower() + role = request.form.get("role", "user") + is_active = request.form.get("is_active") == "on" + client_portal_enabled = request.form.get("client_portal_enabled") == "on" + client_id = request.form.get("client_id", "").strip() + if not username: - flash(_('Username is required'), 'error') - return render_template('admin/user_form.html', user=user, clients=clients) - + flash(_("Username is required"), "error") + return render_template("admin/user_form.html", user=user, clients=clients) + # Check if username is already taken by another user existing_user = User.query.filter_by(username=username).first() if existing_user and existing_user.id != user.id: - flash(_('Username already exists'), 'error') - return render_template('admin/user_form.html', user=user, clients=clients) - + flash(_("Username already exists"), "error") + return render_template("admin/user_form.html", user=user, clients=clients) + # Validate client portal settings if client_portal_enabled and not client_id: - flash(_('Please select a client when enabling client portal access.'), 'error') - return render_template('admin/user_form.html', user=user, clients=clients) - + flash(_("Please select a client when enabling client portal access."), "error") + return render_template("admin/user_form.html", user=user, clients=clients) + # Update user user.username = username user.role = role user.is_active = is_active user.client_portal_enabled = client_portal_enabled user.client_id = int(client_id) if client_id else None - - if not safe_commit('admin_edit_user', {'user_id': user.id}): - flash(_('Could not update user due to a database error. Please check server logs.'), 'error') - return render_template('admin/user_form.html', user=user, clients=clients) - - flash(_('User "%(username)s" updated successfully', username=username), 'success') - return redirect(url_for('admin.list_users')) - - return render_template('admin/user_form.html', user=user, clients=clients) - -@admin_bp.route('/admin/users//delete', methods=['POST']) + + if not safe_commit("admin_edit_user", {"user_id": user.id}): + flash(_("Could not update user due to a database error. Please check server logs."), "error") + return render_template("admin/user_form.html", user=user, clients=clients) + + flash(_('User "%(username)s" updated successfully', username=username), "success") + return redirect(url_for("admin.list_users")) + + return render_template("admin/user_form.html", user=user, clients=clients) + + +@admin_bp.route("/admin/users//delete", methods=["POST"]) @login_required -@admin_or_permission_required('delete_users') +@admin_or_permission_required("delete_users") def delete_user(user_id): """Delete a user""" user = User.query.get_or_404(user_id) - + # Don't allow deleting the last admin if user.is_admin: - admin_count = User.query.filter_by(role='admin', is_active=True).count() + admin_count = User.query.filter_by(role="admin", is_active=True).count() if admin_count <= 1: - flash(_('Cannot delete the last administrator'), 'error') - return redirect(url_for('admin.list_users')) - + flash(_("Cannot delete the last administrator"), "error") + return redirect(url_for("admin.list_users")) + # Don't allow deleting users with time entries if user.time_entries.count() > 0: - flash(_('Cannot delete user with existing time entries'), 'error') - return redirect(url_for('admin.list_users')) - + flash(_("Cannot delete user with existing time entries"), "error") + return redirect(url_for("admin.list_users")) + username = user.username db.session.delete(user) - if not safe_commit('admin_delete_user', {'user_id': user.id}): - flash(_('Could not delete user due to a database error. Please check server logs.'), 'error') - return redirect(url_for('admin.list_users')) - - flash(_('User "%(username)s" deleted successfully', username=username), 'success') - return redirect(url_for('admin.list_users')) - -@admin_bp.route('/admin/telemetry') + if not safe_commit("admin_delete_user", {"user_id": user.id}): + flash(_("Could not delete user due to a database error. Please check server logs."), "error") + return redirect(url_for("admin.list_users")) + + flash(_('User "%(username)s" deleted successfully', username=username), "success") + return redirect(url_for("admin.list_users")) + + +@admin_bp.route("/admin/telemetry") @login_required -@admin_or_permission_required('manage_telemetry') +@admin_or_permission_required("manage_telemetry") def telemetry_dashboard(): """Telemetry and analytics dashboard""" installation_config = get_installation_config() - + # Get telemetry status telemetry_data = { - 'enabled': is_telemetry_enabled(), - 'setup_complete': installation_config.is_setup_complete(), - 'installation_id': installation_config.get_installation_id(), - 'telemetry_salt': installation_config.get_installation_salt()[:16] + '...', # Show partial salt - 'fingerprint': get_telemetry_fingerprint(), - 'config': installation_config.get_all_config() + "enabled": is_telemetry_enabled(), + "setup_complete": installation_config.is_setup_complete(), + "installation_id": installation_config.get_installation_id(), + "telemetry_salt": installation_config.get_installation_salt()[:16] + "...", # Show partial salt + "fingerprint": get_telemetry_fingerprint(), + "config": installation_config.get_all_config(), } - + # Get PostHog status posthog_data = { - 'enabled': bool(os.getenv('POSTHOG_API_KEY')), - 'host': os.getenv('POSTHOG_HOST', 'https://app.posthog.com'), - 'api_key_set': bool(os.getenv('POSTHOG_API_KEY')) + "enabled": bool(os.getenv("POSTHOG_API_KEY")), + "host": os.getenv("POSTHOG_HOST", "https://app.posthog.com"), + "api_key_set": bool(os.getenv("POSTHOG_API_KEY")), } - + # Get Sentry status sentry_data = { - 'enabled': bool(os.getenv('SENTRY_DSN')), - 'dsn_set': bool(os.getenv('SENTRY_DSN')), - 'traces_rate': os.getenv('SENTRY_TRACES_RATE', '0.0') + "enabled": bool(os.getenv("SENTRY_DSN")), + "dsn_set": bool(os.getenv("SENTRY_DSN")), + "traces_rate": os.getenv("SENTRY_TRACES_RATE", "0.0"), } - + # Log dashboard access app_module.log_event("admin.telemetry_dashboard_viewed", user_id=current_user.id) app_module.track_event(current_user.id, "admin.telemetry_dashboard_viewed", {}) - - return render_template('admin/telemetry.html', - telemetry=telemetry_data, - posthog=posthog_data, - sentry=sentry_data) + return render_template("admin/telemetry.html", telemetry=telemetry_data, posthog=posthog_data, sentry=sentry_data) -@admin_bp.route('/admin/telemetry/toggle', methods=['POST']) + +@admin_bp.route("/admin/telemetry/toggle", methods=["POST"]) @login_required -@admin_or_permission_required('manage_telemetry') +@admin_or_permission_required("manage_telemetry") def toggle_telemetry(): """Toggle telemetry on/off""" installation_config = get_installation_config() current_state = installation_config.get_telemetry_preference() new_state = not current_state - + installation_config.set_telemetry_preference(new_state) - + # Log the change app_module.log_event("admin.telemetry_toggled", user_id=current_user.id, new_state=new_state) app_module.track_event(current_user.id, "admin.telemetry_toggled", {"enabled": new_state}) - + if new_state: - flash(_('Telemetry has been enabled. Thank you for helping us improve!'), 'success') + flash(_("Telemetry has been enabled. Thank you for helping us improve!"), "success") else: - flash(_('Telemetry has been disabled.'), 'info') - - return redirect(url_for('admin.telemetry_dashboard')) + flash(_("Telemetry has been disabled."), "info") + + return redirect(url_for("admin.telemetry_dashboard")) -@admin_bp.route('/admin/clear-cache') +@admin_bp.route("/admin/clear-cache") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def clear_cache(): """Cache clearing utility page""" - return render_template('admin/clear_cache.html') + return render_template("admin/clear_cache.html") -@admin_bp.route('/admin/settings', methods=['GET', 'POST']) + +@admin_bp.route("/admin/settings", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def settings(): """Manage system settings""" settings_obj = Settings.get_settings() installation_config = get_installation_config() timezones = get_available_timezones() - + # Sync analytics preference from installation config to database on load # (installation config is the source of truth for telemetry) if settings_obj.allow_analytics != installation_config.get_telemetry_preference(): settings_obj.allow_analytics = installation_config.get_telemetry_preference() db.session.commit() - + # Prepare kiosk settings with safe defaults (in case migration hasn't run) kiosk_settings = { - 'kiosk_mode_enabled': getattr(settings_obj, 'kiosk_mode_enabled', False), - 'kiosk_auto_logout_minutes': getattr(settings_obj, 'kiosk_auto_logout_minutes', 15), - 'kiosk_allow_camera_scanning': getattr(settings_obj, 'kiosk_allow_camera_scanning', True), - 'kiosk_require_reason_for_adjustments': getattr(settings_obj, 'kiosk_require_reason_for_adjustments', False), - 'kiosk_default_movement_type': getattr(settings_obj, 'kiosk_default_movement_type', 'adjustment') + "kiosk_mode_enabled": getattr(settings_obj, "kiosk_mode_enabled", False), + "kiosk_auto_logout_minutes": getattr(settings_obj, "kiosk_auto_logout_minutes", 15), + "kiosk_allow_camera_scanning": getattr(settings_obj, "kiosk_allow_camera_scanning", True), + "kiosk_require_reason_for_adjustments": getattr(settings_obj, "kiosk_require_reason_for_adjustments", False), + "kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"), } - - if request.method == 'POST': + + if request.method == "POST": # Validate timezone - timezone = request.form.get('timezone') or settings_obj.timezone + timezone = request.form.get("timezone") or settings_obj.timezone try: import pytz + pytz.timezone(timezone) # This will raise an exception if timezone is invalid except pytz.exceptions.UnknownTimeZoneError: - flash(_('Invalid timezone: %(timezone)s', timezone=timezone), 'error') - return render_template('admin/settings.html', settings=settings_obj, timezones=timezones) - + flash(_("Invalid timezone: %(timezone)s", timezone=timezone), "error") + return render_template("admin/settings.html", settings=settings_obj, timezones=timezones) + # Update basic settings settings_obj.timezone = timezone - settings_obj.currency = request.form.get('currency', 'EUR') - settings_obj.rounding_minutes = int(request.form.get('rounding_minutes', 1)) - settings_obj.single_active_timer = request.form.get('single_active_timer') == 'on' - settings_obj.allow_self_register = request.form.get('allow_self_register') == 'on' - settings_obj.idle_timeout_minutes = int(request.form.get('idle_timeout_minutes', 30)) - settings_obj.backup_retention_days = int(request.form.get('backup_retention_days', 30)) - settings_obj.backup_time = request.form.get('backup_time', '02:00') - settings_obj.export_delimiter = request.form.get('export_delimiter', ',') - + settings_obj.currency = request.form.get("currency", "EUR") + settings_obj.rounding_minutes = int(request.form.get("rounding_minutes", 1)) + settings_obj.single_active_timer = request.form.get("single_active_timer") == "on" + settings_obj.allow_self_register = request.form.get("allow_self_register") == "on" + settings_obj.idle_timeout_minutes = int(request.form.get("idle_timeout_minutes", 30)) + settings_obj.backup_retention_days = int(request.form.get("backup_retention_days", 30)) + settings_obj.backup_time = request.form.get("backup_time", "02:00") + settings_obj.export_delimiter = request.form.get("export_delimiter", ",") + # Update company branding settings - settings_obj.company_name = request.form.get('company_name', 'Your Company Name') - settings_obj.company_address = request.form.get('company_address', 'Your Company Address') - settings_obj.company_email = request.form.get('company_email', 'info@yourcompany.com') - settings_obj.company_phone = request.form.get('company_phone', '+1 (555) 123-4567') - settings_obj.company_website = request.form.get('company_website', 'www.yourcompany.com') - settings_obj.company_tax_id = request.form.get('company_tax_id', '') - settings_obj.company_bank_info = request.form.get('company_bank_info', '') - + settings_obj.company_name = request.form.get("company_name", "Your Company Name") + settings_obj.company_address = request.form.get("company_address", "Your Company Address") + settings_obj.company_email = request.form.get("company_email", "info@yourcompany.com") + settings_obj.company_phone = request.form.get("company_phone", "+1 (555) 123-4567") + settings_obj.company_website = request.form.get("company_website", "www.yourcompany.com") + settings_obj.company_tax_id = request.form.get("company_tax_id", "") + settings_obj.company_bank_info = request.form.get("company_bank_info", "") + # Update invoice defaults - settings_obj.invoice_prefix = request.form.get('invoice_prefix', 'INV') - settings_obj.invoice_start_number = int(request.form.get('invoice_start_number', 1000)) - settings_obj.invoice_terms = request.form.get('invoice_terms', 'Payment is due within 30 days of invoice date.') - settings_obj.invoice_notes = request.form.get('invoice_notes', 'Thank you for your business!') - + settings_obj.invoice_prefix = request.form.get("invoice_prefix", "INV") + settings_obj.invoice_start_number = int(request.form.get("invoice_start_number", 1000)) + settings_obj.invoice_terms = request.form.get("invoice_terms", "Payment is due within 30 days of invoice date.") + settings_obj.invoice_notes = request.form.get("invoice_notes", "Thank you for your business!") + # Update kiosk mode settings (if columns exist) try: - settings_obj.kiosk_mode_enabled = request.form.get('kiosk_mode_enabled') == 'on' - settings_obj.kiosk_auto_logout_minutes = int(request.form.get('kiosk_auto_logout_minutes', 15)) - settings_obj.kiosk_allow_camera_scanning = request.form.get('kiosk_allow_camera_scanning') == 'on' - settings_obj.kiosk_require_reason_for_adjustments = request.form.get('kiosk_require_reason_for_adjustments') == 'on' - settings_obj.kiosk_default_movement_type = request.form.get('kiosk_default_movement_type', 'adjustment') + settings_obj.kiosk_mode_enabled = request.form.get("kiosk_mode_enabled") == "on" + settings_obj.kiosk_auto_logout_minutes = int(request.form.get("kiosk_auto_logout_minutes", 15)) + settings_obj.kiosk_allow_camera_scanning = request.form.get("kiosk_allow_camera_scanning") == "on" + settings_obj.kiosk_require_reason_for_adjustments = ( + request.form.get("kiosk_require_reason_for_adjustments") == "on" + ) + settings_obj.kiosk_default_movement_type = request.form.get("kiosk_default_movement_type", "adjustment") except AttributeError: # Kiosk columns don't exist yet (migration not run) pass - + # Update integration OAuth credentials (if columns exist) try: - if 'jira_client_id' in request.form: - settings_obj.jira_client_id = request.form.get('jira_client_id', '').strip() - if 'jira_client_secret' in request.form: - new_secret = request.form.get('jira_client_secret', '').strip() + if "jira_client_id" in request.form: + settings_obj.jira_client_id = request.form.get("jira_client_id", "").strip() + if "jira_client_secret" in request.form: + new_secret = request.form.get("jira_client_secret", "").strip() # Only update if a new value is provided (don't clear if empty) if new_secret: settings_obj.jira_client_secret = new_secret - - if 'slack_client_id' in request.form: - settings_obj.slack_client_id = request.form.get('slack_client_id', '').strip() - if 'slack_client_secret' in request.form: - new_secret = request.form.get('slack_client_secret', '').strip() + + if "slack_client_id" in request.form: + settings_obj.slack_client_id = request.form.get("slack_client_id", "").strip() + if "slack_client_secret" in request.form: + new_secret = request.form.get("slack_client_secret", "").strip() if new_secret: settings_obj.slack_client_secret = new_secret - - if 'github_client_id' in request.form: - settings_obj.github_client_id = request.form.get('github_client_id', '').strip() - if 'github_client_secret' in request.form: - new_secret = request.form.get('github_client_secret', '').strip() + + if "github_client_id" in request.form: + settings_obj.github_client_id = request.form.get("github_client_id", "").strip() + if "github_client_secret" in request.form: + new_secret = request.form.get("github_client_secret", "").strip() if new_secret: settings_obj.github_client_secret = new_secret except AttributeError: # Integration credential columns don't exist yet (migration not run) pass - + # Update privacy and analytics settings - allow_analytics = request.form.get('allow_analytics') == 'on' + allow_analytics = request.form.get("allow_analytics") == "on" old_analytics_state = settings_obj.allow_analytics settings_obj.allow_analytics = allow_analytics - + # Also update the installation config (used by telemetry system) # This ensures the telemetry system sees the updated preference installation_config.set_telemetry_preference(allow_analytics) - + # Log analytics preference change if it changed if old_analytics_state != allow_analytics: app_module.log_event("admin.analytics_toggled", user_id=current_user.id, new_state=allow_analytics) app_module.track_event(current_user.id, "admin.analytics_toggled", {"enabled": allow_analytics}) - - if not safe_commit('admin_update_settings'): - flash(_('Could not update settings due to a database error. Please check server logs.'), 'error') - return render_template('admin/settings.html', settings=settings_obj, timezones=timezones, kiosk_settings=kiosk_settings) - flash(_('Settings updated successfully'), 'success') - return redirect(url_for('admin.settings')) - + + if not safe_commit("admin_update_settings"): + flash(_("Could not update settings due to a database error. Please check server logs."), "error") + return render_template( + "admin/settings.html", settings=settings_obj, timezones=timezones, kiosk_settings=kiosk_settings + ) + flash(_("Settings updated successfully"), "success") + return redirect(url_for("admin.settings")) + # Update kiosk_settings after potential POST update kiosk_settings = { - 'kiosk_mode_enabled': getattr(settings_obj, 'kiosk_mode_enabled', False), - 'kiosk_auto_logout_minutes': getattr(settings_obj, 'kiosk_auto_logout_minutes', 15), - 'kiosk_allow_camera_scanning': getattr(settings_obj, 'kiosk_allow_camera_scanning', True), - 'kiosk_require_reason_for_adjustments': getattr(settings_obj, 'kiosk_require_reason_for_adjustments', False), - 'kiosk_default_movement_type': getattr(settings_obj, 'kiosk_default_movement_type', 'adjustment') + "kiosk_mode_enabled": getattr(settings_obj, "kiosk_mode_enabled", False), + "kiosk_auto_logout_minutes": getattr(settings_obj, "kiosk_auto_logout_minutes", 15), + "kiosk_allow_camera_scanning": getattr(settings_obj, "kiosk_allow_camera_scanning", True), + "kiosk_require_reason_for_adjustments": getattr(settings_obj, "kiosk_require_reason_for_adjustments", False), + "kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"), } - - return render_template('admin/settings.html', settings=settings_obj, timezones=timezones, kiosk_settings=kiosk_settings) + + return render_template( + "admin/settings.html", settings=settings_obj, timezones=timezones, kiosk_settings=kiosk_settings + ) -@admin_bp.route('/admin/pdf-layout', methods=['GET', 'POST']) +@admin_bp.route("/admin/pdf-layout", methods=["GET", "POST"]) @limiter.limit("30 per minute", methods=["POST"]) # editor saves @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def pdf_layout(): """Edit PDF invoice layout template (HTML and CSS) by page size.""" from app.models import InvoicePDFTemplate - + # Get page size from query parameter or form, default to A4 - page_size = request.args.get('size', request.form.get('page_size', 'A4')) - + page_size = request.args.get("size", request.form.get("page_size", "A4")) + # Ensure valid page size - valid_sizes = ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid'] + valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: - page_size = 'A4' - + page_size = "A4" + # Get or create template for this page size template = InvoicePDFTemplate.get_template(page_size) - - if request.method == 'POST': - html_template = request.form.get('invoice_pdf_template_html', '') - css_template = request.form.get('invoice_pdf_template_css', '') - design_json = request.form.get('design_json', '') - + + if request.method == "POST": + html_template = request.form.get("invoice_pdf_template_html", "") + css_template = request.form.get("invoice_pdf_template_css", "") + design_json = request.form.get("design_json", "") + # Update template template.template_html = html_template template.template_css = css_template template.design_json = design_json template.updated_at = datetime.utcnow() - + # For backwards compatibility, also update Settings when saving A4 (default) - if page_size == 'A4': + if page_size == "A4": settings_obj = Settings.get_settings() settings_obj.invoice_pdf_template_html = html_template settings_obj.invoice_pdf_template_css = css_template settings_obj.invoice_pdf_design_json = design_json - - if not safe_commit('admin_update_pdf_layout'): + + if not safe_commit("admin_update_pdf_layout"): from flask_babel import gettext as _ - flash(_('Could not update PDF layout due to a database error.'), 'error') + + flash(_("Could not update PDF layout due to a database error."), "error") else: from flask_babel import gettext as _ - flash(_('PDF layout updated successfully'), 'success') - return redirect(url_for('admin.pdf_layout', size=page_size)) - + + flash(_("PDF layout updated successfully"), "success") + return redirect(url_for("admin.pdf_layout", size=page_size)) + # Get all templates for dropdown all_templates = InvoicePDFTemplate.get_all_templates() - + # Provide initial defaults to the template if no custom HTML/CSS saved - initial_html = template.template_html or '' - initial_css = template.template_css or '' - design_json = template.design_json or '' - + initial_html = template.template_html or "" + initial_css = template.template_css or "" + design_json = template.design_json or "" + # Fallback to legacy Settings if template is empty if not initial_html and not initial_css: settings_obj = Settings.get_settings() - initial_html = settings_obj.invoice_pdf_template_html or '' - initial_css = settings_obj.invoice_pdf_template_css or '' - design_json = settings_obj.invoice_pdf_design_json or '' - + initial_html = settings_obj.invoice_pdf_template_html or "" + initial_css = settings_obj.invoice_pdf_template_css or "" + design_json = settings_obj.invoice_pdf_design_json or "" + # Load default template if still empty try: if not initial_html: env = current_app.jinja_env - html_src, _, _ = env.loader.get_source(env, 'invoices/pdf_default.html') + html_src, _, _ = env.loader.get_source(env, "invoices/pdf_default.html") # Extract body only for editor try: import re as _re - m = _re.search(r']*>([\s\S]*?)', html_src, _re.IGNORECASE) - initial_html = (m.group(1).strip() if m else html_src) + + m = _re.search(r"]*>([\s\S]*?)", html_src, _re.IGNORECASE) + initial_html = m.group(1).strip() if m else html_src except Exception: pass if not initial_css: env = current_app.jinja_env - css_src, _, _ = env.loader.get_source(env, 'invoices/pdf_styles_default.css') + css_src, _, _ = env.loader.get_source(env, "invoices/pdf_styles_default.css") initial_css = css_src except Exception: pass - - return render_template('admin/pdf_layout.html', - settings=Settings.get_settings(), - initial_html=initial_html, - initial_css=initial_css, - design_json=design_json, - page_size=page_size, - all_templates=all_templates) + + return render_template( + "admin/pdf_layout.html", + settings=Settings.get_settings(), + initial_html=initial_html, + initial_css=initial_css, + design_json=design_json, + page_size=page_size, + all_templates=all_templates, + ) -@admin_bp.route('/admin/pdf-layout/reset', methods=['POST']) +@admin_bp.route("/admin/pdf-layout/reset", methods=["POST"]) @limiter.limit("10 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def pdf_layout_reset(): """Reset PDF layout to defaults (clear custom templates).""" settings_obj = Settings.get_settings() - - settings_obj.invoice_pdf_template_html = '' - settings_obj.invoice_pdf_template_css = '' - settings_obj.invoice_pdf_design_json = '' - if not safe_commit('admin_reset_pdf_layout'): - flash(_('Could not reset PDF layout due to a database error.'), 'error') + + settings_obj.invoice_pdf_template_html = "" + settings_obj.invoice_pdf_template_css = "" + settings_obj.invoice_pdf_design_json = "" + if not safe_commit("admin_reset_pdf_layout"): + flash(_("Could not reset PDF layout due to a database error."), "error") else: - flash(_('PDF layout reset to defaults'), 'success') - return redirect(url_for('admin.pdf_layout')) + flash(_("PDF layout reset to defaults"), "success") + return redirect(url_for("admin.pdf_layout")) -@admin_bp.route('/admin/quote-pdf-layout', methods=['GET', 'POST']) +@admin_bp.route("/admin/quote-pdf-layout", methods=["GET", "POST"]) @limiter.limit("30 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def quote_pdf_layout(): """Edit PDF quote layout template (HTML and CSS) by page size.""" from app.models import QuotePDFTemplate - + # Get page size from query parameter or form, default to A4 - page_size = request.args.get('size', request.form.get('page_size', 'A4')) - + page_size = request.args.get("size", request.form.get("page_size", "A4")) + # Ensure valid page size - valid_sizes = ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid'] + valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: - page_size = 'A4' - + page_size = "A4" + # Get or create template for this page size template = QuotePDFTemplate.get_template(page_size) - - if request.method == 'POST': - html_template = request.form.get('quote_pdf_template_html', '') - css_template = request.form.get('quote_pdf_template_css', '') - design_json = request.form.get('design_json', '') - + + if request.method == "POST": + html_template = request.form.get("quote_pdf_template_html", "") + css_template = request.form.get("quote_pdf_template_css", "") + design_json = request.form.get("design_json", "") + # Update template template.template_html = html_template template.template_css = css_template template.design_json = design_json template.updated_at = datetime.utcnow() - - if not safe_commit('admin_update_quote_pdf_layout'): - flash(_('Could not update PDF layout due to a database error.'), 'error') + + if not safe_commit("admin_update_quote_pdf_layout"): + flash(_("Could not update PDF layout due to a database error."), "error") else: - flash(_('PDF layout updated successfully'), 'success') - return redirect(url_for('admin.quote_pdf_layout', size=page_size)) - + flash(_("PDF layout updated successfully"), "success") + return redirect(url_for("admin.quote_pdf_layout", size=page_size)) + # Get all templates for dropdown all_templates = QuotePDFTemplate.get_all_templates() - + # Provide initial defaults - initial_html = template.template_html or '' - initial_css = template.template_css or '' - design_json = template.design_json or '' - + initial_html = template.template_html or "" + initial_css = template.template_css or "" + design_json = template.design_json or "" + # Load default template if empty try: if not initial_html: env = current_app.jinja_env - html_src, _unused1, _unused2 = env.loader.get_source(env, 'quotes/pdf_default.html') + html_src, _unused1, _unused2 = env.loader.get_source(env, "quotes/pdf_default.html") try: import re as _re - m = _re.search(r']*>([\s\S]*?)', html_src, _re.IGNORECASE) - initial_html = (m.group(1).strip() if m else html_src) + + m = _re.search(r"]*>([\s\S]*?)", html_src, _re.IGNORECASE) + initial_html = m.group(1).strip() if m else html_src except Exception: pass if not initial_css: env = current_app.jinja_env - css_src, _unused3, _unused4 = env.loader.get_source(env, 'quotes/pdf_styles_default.css') + css_src, _unused3, _unused4 = env.loader.get_source(env, "quotes/pdf_styles_default.css") initial_css = css_src except Exception: pass - - return render_template('admin/quote_pdf_layout.html', - settings=Settings.get_settings(), - initial_html=initial_html, - initial_css=initial_css, - design_json=design_json, - page_size=page_size, - all_templates=all_templates) + + return render_template( + "admin/quote_pdf_layout.html", + settings=Settings.get_settings(), + initial_html=initial_html, + initial_css=initial_css, + design_json=design_json, + page_size=page_size, + all_templates=all_templates, + ) -@admin_bp.route('/admin/quote-pdf-layout/reset', methods=['POST']) +@admin_bp.route("/admin/quote-pdf-layout/reset", methods=["POST"]) @limiter.limit("10 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def quote_pdf_layout_reset(): """Reset quote PDF layout to defaults (clear custom templates).""" from app.models import QuotePDFTemplate - + # Get page size from query parameter or form, default to A4 - page_size = request.args.get('size', request.form.get('page_size', 'A4')) - + page_size = request.args.get("size", request.form.get("page_size", "A4")) + # Ensure valid page size - valid_sizes = ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid'] + valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: - page_size = 'A4' - + page_size = "A4" + # Get or create template for this page size template = QuotePDFTemplate.get_template(page_size) - + # Clear template - template.template_html = '' - template.template_css = '' - template.design_json = '' + template.template_html = "" + template.template_css = "" + template.design_json = "" template.updated_at = datetime.utcnow() - - if not safe_commit('admin_reset_quote_pdf_layout'): - flash(_('Could not reset PDF layout due to a database error.'), 'error') + + if not safe_commit("admin_reset_quote_pdf_layout"): + flash(_("Could not reset PDF layout due to a database error."), "error") else: - flash(_('PDF layout reset to defaults'), 'success') - return redirect(url_for('admin.quote_pdf_layout', size=page_size)) + flash(_("PDF layout reset to defaults"), "success") + return redirect(url_for("admin.quote_pdf_layout", size=page_size)) -@admin_bp.route('/admin/pdf-layout/debug', methods=['GET']) +@admin_bp.route("/admin/pdf-layout/debug", methods=["GET"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def pdf_layout_debug(): """Debug endpoint to show what's saved in the database""" settings_obj = Settings.get_settings() - - html = settings_obj.invoice_pdf_template_html or '' - css = settings_obj.invoice_pdf_template_css or '' - design_json = settings_obj.invoice_pdf_design_json or '' - + + html = settings_obj.invoice_pdf_template_html or "" + css = settings_obj.invoice_pdf_template_css or "" + design_json = settings_obj.invoice_pdf_design_json or "" + # Check for bugs - has_all_bug = 'invoice.items.all()' in html - has_if_bug = 'invoice.items and invoice.items.all()' in html - + has_all_bug = "invoice.items.all()" in html + has_if_bug = "invoice.items and invoice.items.all()" in html + # Get invoice info for testing from app.models import Invoice + test_invoice = Invoice.query.order_by(Invoice.id.desc()).first() - + debug_info = { - 'saved_template': { - 'html_length': len(html), - 'css_length': len(css), - 'design_json_length': len(design_json), - 'has_html': bool(html), - 'has_bugs': has_all_bug or has_if_bug, - 'bugs_found': [] + "saved_template": { + "html_length": len(html), + "css_length": len(css), + "design_json_length": len(design_json), + "has_html": bool(html), + "has_bugs": has_all_bug or has_if_bug, + "bugs_found": [], + }, + "test_invoice": { + "exists": test_invoice is not None, + "invoice_number": test_invoice.invoice_number if test_invoice else None, + "items_count": test_invoice.items.count() if test_invoice else 0, }, - 'test_invoice': { - 'exists': test_invoice is not None, - 'invoice_number': test_invoice.invoice_number if test_invoice else None, - 'items_count': test_invoice.items.count() if test_invoice else 0, - } } - + if has_all_bug: - debug_info['saved_template']['bugs_found'].append('invoice.items.all() found in template') + debug_info["saved_template"]["bugs_found"].append("invoice.items.all() found in template") if has_if_bug: - debug_info['saved_template']['bugs_found'].append('invoice.items and invoice.items.all() found in template') - + debug_info["saved_template"]["bugs_found"].append("invoice.items and invoice.items.all() found in template") + # Show snippets of problematic code if has_all_bug or has_if_bug: import re - matches = re.finditer(r'.{0,50}invoice\.items\.all\(\).{0,50}', html) - debug_info['saved_template']['bug_snippets'] = [m.group() for m in matches] - + + matches = re.finditer(r".{0,50}invoice\.items\.all\(\).{0,50}", html) + debug_info["saved_template"]["bug_snippets"] = [m.group() for m in matches] + return jsonify(debug_info) -@admin_bp.route('/admin/pdf-layout/default', methods=['GET']) +@admin_bp.route("/admin/pdf-layout/default", methods=["GET"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def pdf_layout_default(): """Return default HTML and CSS template sources for the PDF layout editor.""" try: env = current_app.jinja_env # Get raw template sources, not rendered - html_src, _, _ = env.loader.get_source(env, 'invoices/pdf_default.html') + html_src, _, _ = env.loader.get_source(env, "invoices/pdf_default.html") # Extract only the body content for GrapesJS try: import re as _re - match = _re.search(r']*>([\s\S]*?)', html_src, _re.IGNORECASE) + + match = _re.search(r"]*>([\s\S]*?)", html_src, _re.IGNORECASE) if match: html_src = match.group(1).strip() except Exception: pass except Exception: - html_src = '

{{ _(\'INVOICE\') }} {{ invoice.invoice_number }}

' + html_src = "

{{ _('INVOICE') }} {{ invoice.invoice_number }}

" try: - css_src, _, _ = env.loader.get_source(env, 'invoices/pdf_styles_default.css') + css_src, _, _ = env.loader.get_source(env, "invoices/pdf_styles_default.css") except Exception: - css_src = '' - return jsonify({ - 'html': html_src, - 'css': css_src, - }) + css_src = "" + return jsonify( + { + "html": html_src, + "css": css_src, + } + ) -@admin_bp.route('/admin/pdf-layout/preview', methods=['POST']) +@admin_bp.route("/admin/pdf-layout/preview", methods=["POST"]) @limiter.limit("60 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def pdf_layout_preview(): """Render a live preview of the provided HTML/CSS using an invoice context.""" - html = request.form.get('html', '') - css = request.form.get('css', '') - invoice_id = request.form.get('invoice_id', type=int) + html = request.form.get("html", "") + css = request.form.get("css", "") + invoice_id = request.form.get("invoice_id", type=int) invoice = None if invoice_id: invoice = Invoice.query.get(invoice_id) if invoice is None: invoice = Invoice.query.order_by(Invoice.id.desc()).first() settings_obj = Settings.get_settings() - + # Provide a minimal mock invoice if none exists to avoid template errors from types import SimpleNamespace + if invoice is None: from datetime import date + invoice = SimpleNamespace( - invoice_number='0000', + invoice_number="0000", issue_date=date.today(), due_date=date.today(), - status='draft', - client_name='Sample Client', - client_email='', - client_address='', - project=SimpleNamespace(name='Sample Project', description=''), + status="draft", + client_name="Sample Client", + client_email="", + client_address="", + project=SimpleNamespace(name="Sample Project", description=""), items=[], extra_goods=[], subtotal=0.0, tax_rate=0.0, tax_amount=0.0, total_amount=0.0, - notes='', - terms='', + notes="", + terms="", ) # Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops - sample_item = SimpleNamespace(description='Sample item', quantity=1.0, unit_price=0.0, total_amount=0.0, time_entry_ids='') - + sample_item = SimpleNamespace( + description="Sample item", quantity=1.0, unit_price=0.0, total_amount=0.0, time_entry_ids="" + ) + # Create a wrapper object with converted Query objects to lists # We can't modify SQLAlchemy model attributes directly, so we create a wrapper invoice_wrapper = SimpleNamespace() - + # Copy all simple attributes from the invoice - for attr in ['id', 'invoice_number', 'project_id', 'client_name', 'client_email', - 'client_address', 'client_id', 'issue_date', 'due_date', 'status', - 'subtotal', 'tax_rate', 'tax_amount', 'total_amount', 'currency_code', - 'notes', 'terms', 'payment_date', 'payment_method', 'payment_reference', - 'payment_notes', 'amount_paid', 'payment_status', 'created_by', - 'created_at', 'updated_at']: + for attr in [ + "id", + "invoice_number", + "project_id", + "client_name", + "client_email", + "client_address", + "client_id", + "issue_date", + "due_date", + "status", + "subtotal", + "tax_rate", + "tax_amount", + "total_amount", + "currency_code", + "notes", + "terms", + "payment_date", + "payment_method", + "payment_reference", + "payment_notes", + "amount_paid", + "payment_status", + "created_by", + "created_at", + "updated_at", + ]: try: setattr(invoice_wrapper, attr, getattr(invoice, attr)) except AttributeError: pass - + # Copy relationship attributes (project, client) try: invoice_wrapper.project = invoice.project except: - invoice_wrapper.project = SimpleNamespace(name='Sample Project', description='') - + invoice_wrapper.project = SimpleNamespace(name="Sample Project", description="") + try: invoice_wrapper.client = invoice.client except: invoice_wrapper.client = None - + # Convert items from Query to list try: - if hasattr(invoice, 'items') and hasattr(invoice.items, 'all'): + if hasattr(invoice, "items") and hasattr(invoice.items, "all"): # It's a SQLAlchemy Query object - call .all() to get list items_list = invoice.items.all() if not items_list: # No items in database, add sample items_list = [sample_item] invoice_wrapper.items = items_list - elif hasattr(invoice, 'items') and isinstance(invoice.items, list): + elif hasattr(invoice, "items") and isinstance(invoice.items, list): # Already a list invoice_wrapper.items = invoice.items if invoice.items else [sample_item] else: @@ -828,63 +896,72 @@ def pdf_layout_preview(): except Exception as e: print(f"Error converting invoice items: {e}") invoice_wrapper.items = [sample_item] - + # Convert extra_goods from Query to list try: - if hasattr(invoice, 'extra_goods') and hasattr(invoice.extra_goods, 'all'): + if hasattr(invoice, "extra_goods") and hasattr(invoice.extra_goods, "all"): invoice_wrapper.extra_goods = invoice.extra_goods.all() - elif hasattr(invoice, 'extra_goods') and isinstance(invoice.extra_goods, list): + elif hasattr(invoice, "extra_goods") and isinstance(invoice.extra_goods, list): invoice_wrapper.extra_goods = invoice.extra_goods else: invoice_wrapper.extra_goods = [] except Exception: invoice_wrapper.extra_goods = [] - + # Convert expenses from Query to list try: - if hasattr(invoice, 'expenses') and hasattr(invoice.expenses, 'all'): + if hasattr(invoice, "expenses") and hasattr(invoice.expenses, "all"): invoice_wrapper.expenses = invoice.expenses.all() - elif hasattr(invoice, 'expenses') and isinstance(invoice.expenses, list): + elif hasattr(invoice, "expenses") and isinstance(invoice.expenses, list): invoice_wrapper.expenses = invoice.expenses else: invoice_wrapper.expenses = [] except Exception: invoice_wrapper.expenses = [] - + # Use the wrapper instead of the original invoice invoice = invoice_wrapper + # Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor def _sanitize_jinja_blocks(raw: str) -> str: try: import re as _re import html as _html + smart_map = { - '\u201c': '"', '\u201d': '"', # “ ” -> " - '\u2018': "'", '\u2019': "'", # ‘ ’ -> ' - '\u00a0': ' ', # nbsp - '\u200b': '', '\u200c': '', '\u200d': '', # zero-width + "\u201c": '"', + "\u201d": '"', # “ ” -> " + "\u2018": "'", + "\u2019": "'", # ‘ ’ -> ' + "\u00a0": " ", # nbsp + "\u200b": "", + "\u200c": "", + "\u200d": "", # zero-width } + def _fix_quotes(s: str) -> str: for k, v in smart_map.items(): s = s.replace(k, v) return s + def _clean(match): open_tag = match.group(1) inner = match.group(2) # Remove any HTML tags GrapesJS may have inserted inside Jinja braces - inner = _re.sub(r']+?>', '', inner) + inner = _re.sub(r"]+?>", "", inner) # Decode HTML entities inner = _html.unescape(inner) # Fix smart quotes and nbsp inner = _fix_quotes(inner) # Trim excessive whitespace around pipes and parentheses - inner = _re.sub(r'\s+\|\s+', ' | ', inner) - inner = _re.sub(r'\(\s+', '(', inner) - inner = _re.sub(r'\s+\)', ')', inner) + inner = _re.sub(r"\s+\|\s+", " | ", inner) + inner = _re.sub(r"\(\s+", "(", inner) + inner = _re.sub(r"\s+\)", ")", inner) # Normalize _("...") -> _('...') inner = inner.replace('_("', "_('").replace('")', "')") return f"{open_tag}{inner}{' }}' if open_tag == '{{ ' else ' %}'}" - pattern = _re.compile(r'({{\s|{%\s)([\s\S]*?)(?:}}|%})') + + pattern = _re.compile(r"({{\s|{%\s)([\s\S]*?)(?:}}|%})") return _re.sub(pattern, _clean, raw) except Exception: return raw @@ -894,30 +971,33 @@ def _clean(match): # Wrap provided HTML with a minimal page and CSS try: from pathlib import Path as _Path + # Provide helpers as callables since templates may use function-style helpers try: from babel.dates import format_date as _babel_format_date except Exception: _babel_format_date = None - def _format_date(value, format='medium'): + + def _format_date(value, format="medium"): try: if _babel_format_date: - if format == 'full': - return _babel_format_date(value, format='full') - if format == 'long': - return _babel_format_date(value, format='long') - if format == 'short': - return _babel_format_date(value, format='short') - return _babel_format_date(value, format='medium') - return value.strftime('%Y-%m-%d') + if format == "full": + return _babel_format_date(value, format="full") + if format == "long": + return _babel_format_date(value, format="long") + if format == "short": + return _babel_format_date(value, format="short") + return _babel_format_date(value, format="medium") + return value.strftime("%Y-%m-%d") except Exception: return str(value) + def _format_money(value): try: return f"{float(value):,.2f} {settings_obj.currency}" except Exception: return f"{value} {settings_obj.currency}" - + # Helper function for logo - converts to base64 data URI def _get_logo_base64(logo_path): try: @@ -925,16 +1005,17 @@ def _get_logo_base64(logo_path): return None import base64 import mimetypes - with open(logo_path, 'rb') as f: - data = base64.b64encode(f.read()).decode('utf-8') + + with open(logo_path, "rb") as f: + data = base64.b64encode(f.read()).decode("utf-8") mime_type, _ = mimetypes.guess_type(logo_path) if not mime_type: - mime_type = 'image/png' - return f'data:{mime_type};base64,{data}' + mime_type = "image/png" + return f"data:{mime_type};base64,{data}" except Exception as e: print(f"Error loading logo: {e}") return None - + body_html = render_template_string( sanitized, invoice=invoice, @@ -947,8 +1028,12 @@ def _get_logo_base64(logo_path): ) except Exception as e: import traceback + error_details = traceback.format_exc() - body_html = f"

Template error:

{str(e)}
{error_details}
" + sanitized + body_html = ( + f"

Template error:

{str(e)}
{error_details}
" + + sanitized + ) # Build complete HTML page with embedded styles page_html = f""" @@ -964,38 +1049,41 @@ def _get_logo_base64(logo_path): """ return page_html -@admin_bp.route('/admin/quote-pdf-layout/preview', methods=['POST']) + +@admin_bp.route("/admin/quote-pdf-layout/preview", methods=["POST"]) @limiter.limit("60 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def quote_pdf_layout_preview(): """Render a live preview of the provided HTML/CSS using a quote context.""" - html = request.form.get('html', '') - css = request.form.get('css', '') - quote_id = request.form.get('quote_id', type=int) + html = request.form.get("html", "") + css = request.form.get("css", "") + quote_id = request.form.get("quote_id", type=int) quote = None if quote_id: quote = Quote.query.get(quote_id) if quote is None: quote = Quote.query.order_by(Quote.id.desc()).first() settings_obj = Settings.get_settings() - + # Provide a minimal mock quote if none exists to avoid template errors from types import SimpleNamespace + if quote is None: from datetime import date, datetime + quote = SimpleNamespace( id=1, - quote_number='Q-0001', - title='Sample Quote', - description='Sample quote description', - status='draft', + quote_number="Q-0001", + title="Sample Quote", + description="Sample quote description", + status="draft", client_id=1, client=SimpleNamespace( - name='Sample Client', - email='client@example.com', - address='123 Sample Street\nSample City, ST 12345', - phone='+1 234 567 8900' + name="Sample Client", + email="client@example.com", + address="123 Sample Street\nSample City, ST 12345", + phone="+1 234 567 8900", ), project_id=None, project=None, @@ -1008,60 +1096,82 @@ def quote_pdf_layout_preview(): tax_rate=0.0, tax_amount=0.0, total_amount=0.0, - currency_code='EUR', + currency_code="EUR", valid_until=date.today(), sent_at=None, accepted_at=None, - notes='', - terms='', + notes="", + terms="", payment_terms=None, created_at=datetime.now(), updated_at=datetime.now(), created_by=1, ) # Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops - sample_item = SimpleNamespace(description='Sample item', quantity=1.0, unit_price=0.0, total_amount=0.0) - + sample_item = SimpleNamespace(description="Sample item", quantity=1.0, unit_price=0.0, total_amount=0.0) + # Create a wrapper object with converted Query objects to lists quote_wrapper = SimpleNamespace() - + # Copy all simple attributes from the quote - for attr in ['id', 'quote_number', 'title', 'description', 'status', 'client_id', 'project_id', - 'subtotal', 'discount_type', 'discount_amount', 'discount_reason', 'coupon_code', - 'tax_rate', 'tax_amount', 'total_amount', 'currency_code', 'valid_until', - 'sent_at', 'accepted_at', 'notes', 'terms', 'payment_terms', - 'created_at', 'updated_at', 'created_by']: + for attr in [ + "id", + "quote_number", + "title", + "description", + "status", + "client_id", + "project_id", + "subtotal", + "discount_type", + "discount_amount", + "discount_reason", + "coupon_code", + "tax_rate", + "tax_amount", + "total_amount", + "currency_code", + "valid_until", + "sent_at", + "accepted_at", + "notes", + "terms", + "payment_terms", + "created_at", + "updated_at", + "created_by", + ]: try: setattr(quote_wrapper, attr, getattr(quote, attr)) except AttributeError: pass - + # Copy relationship attributes (project, client) try: quote_wrapper.project = quote.project except: quote_wrapper.project = None - + try: quote_wrapper.client = quote.client except: quote_wrapper.client = SimpleNamespace( - name='Sample Client', - email='client@example.com', - address='123 Sample Street\nSample City, ST 12345', - phone='+1 234 567 8900' + name="Sample Client", + email="client@example.com", + address="123 Sample Street\nSample City, ST 12345", + phone="+1 234 567 8900", ) - + # Convert items from Query to list try: - if hasattr(quote, 'items') and hasattr(quote.items, 'all'): + if hasattr(quote, "items") and hasattr(quote.items, "all"): # It's a SQLAlchemy Query object - call .all() to get list items_list = quote.items.all() if not items_list: # No items in database, add sample items_list = [sample_item] quote_wrapper.items = items_list - elif hasattr(quote, 'items') and isinstance(quote.items, list): + elif hasattr(quote, "items") and isinstance(quote.items, list): # Already a list quote_wrapper.items = quote.items if quote.items else [sample_item] else: @@ -1070,42 +1180,50 @@ def quote_pdf_layout_preview(): except Exception as e: print(f"Error converting quote items: {e}") quote_wrapper.items = [sample_item] - + # Use the wrapper instead of the original quote quote = quote_wrapper - + # Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor def _sanitize_jinja_blocks(raw: str) -> str: try: import re as _re import html as _html + smart_map = { - '\u201c': '"', '\u201d': '"', # " " -> " - '\u2018': "'", '\u2019': "'", # ' ' -> ' - '\u00a0': ' ', # nbsp - '\u200b': '', '\u200c': '', '\u200d': '', # zero-width + "\u201c": '"', + "\u201d": '"', # " " -> " + "\u2018": "'", + "\u2019": "'", # ' ' -> ' + "\u00a0": " ", # nbsp + "\u200b": "", + "\u200c": "", + "\u200d": "", # zero-width } + def _fix_quotes(s: str) -> str: for k, v in smart_map.items(): s = s.replace(k, v) return s + def _clean(match): open_tag = match.group(1) inner = match.group(2) # Remove any HTML tags GrapesJS may have inserted inside Jinja braces - inner = _re.sub(r']+?>', '', inner) + inner = _re.sub(r"]+?>", "", inner) # Decode HTML entities inner = _html.unescape(inner) # Fix smart quotes and nbsp inner = _fix_quotes(inner) # Trim excessive whitespace around pipes and parentheses - inner = _re.sub(r'\s+\|\s+', ' | ', inner) - inner = _re.sub(r'\(\s+', '(', inner) - inner = _re.sub(r'\s+\)', ')', inner) + inner = _re.sub(r"\s+\|\s+", " | ", inner) + inner = _re.sub(r"\(\s+", "(", inner) + inner = _re.sub(r"\s+\)", ")", inner) # Normalize _("...") -> _('...') inner = inner.replace('_("', "_('").replace('")', "')") return f"{open_tag}{inner}{' }}' if open_tag == '{{ ' else ' %}'}" - pattern = _re.compile(r'({{\s|{%\s)([\s\S]*?)(?:}}|%})') + + pattern = _re.compile(r"({{\s|{%\s)([\s\S]*?)(?:}}|%})") return _re.sub(pattern, _clean, raw) except Exception: return raw @@ -1115,30 +1233,33 @@ def _clean(match): # Wrap provided HTML with a minimal page and CSS try: from pathlib import Path as _Path + # Provide helpers as callables since templates may use function-style helpers try: from babel.dates import format_date as _babel_format_date except Exception: _babel_format_date = None - def _format_date(value, format='medium'): + + def _format_date(value, format="medium"): try: if _babel_format_date: - if format == 'full': - return _babel_format_date(value, format='full') - if format == 'long': - return _babel_format_date(value, format='long') - if format == 'short': - return _babel_format_date(value, format='short') - return _babel_format_date(value, format='medium') - return value.strftime('%Y-%m-%d') + if format == "full": + return _babel_format_date(value, format="full") + if format == "long": + return _babel_format_date(value, format="long") + if format == "short": + return _babel_format_date(value, format="short") + return _babel_format_date(value, format="medium") + return value.strftime("%Y-%m-%d") except Exception: return str(value) + def _format_money(value): try: return f"{float(value):,.2f} {settings_obj.currency}" except Exception: return f"{value} {settings_obj.currency}" - + # Helper function for logo - converts to base64 data URI def _get_logo_base64(logo_path): try: @@ -1146,16 +1267,17 @@ def _get_logo_base64(logo_path): return None import base64 import mimetypes - with open(logo_path, 'rb') as f: - data = base64.b64encode(f.read()).decode('utf-8') + + with open(logo_path, "rb") as f: + data = base64.b64encode(f.read()).decode("utf-8") mime_type, _ = mimetypes.guess_type(logo_path) if not mime_type: - mime_type = 'image/png' - return f'data:{mime_type};base64,{data}' + mime_type = "image/png" + return f"data:{mime_type};base64,{data}" except Exception as e: print(f"Error loading logo: {e}") return None - + body_html = render_template_string( sanitized, quote=quote, @@ -1168,8 +1290,12 @@ def _get_logo_base64(logo_path): ) except Exception as e: import traceback + error_details = traceback.format_exc() - body_html = f"

Template error:

{str(e)}
{error_details}
" + sanitized + body_html = ( + f"

Template error:

{str(e)}
{error_details}
" + + sanitized + ) # Build complete HTML page with embedded styles page_html = f""" @@ -1185,50 +1311,54 @@ def _get_logo_base64(logo_path): """ return page_html -@admin_bp.route('/admin/upload-logo', methods=['POST']) + +@admin_bp.route("/admin/upload-logo", methods=["POST"]) @limiter.limit("10 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def upload_logo(): """Upload company logo""" - if 'logo' not in request.files: - flash(_('No logo file selected'), 'error') - return redirect(url_for('admin.settings')) - - file = request.files['logo'] - if file.filename == '': - flash(_('No logo file selected'), 'error') - return redirect(url_for('admin.settings')) - + if "logo" not in request.files: + flash(_("No logo file selected"), "error") + return redirect(url_for("admin.settings")) + + file = request.files["logo"] + if file.filename == "": + flash(_("No logo file selected"), "error") + return redirect(url_for("admin.settings")) + if file and allowed_logo_file(file.filename): # Generate unique filename - file_extension = file.filename.rsplit('.', 1)[1].lower() + file_extension = file.filename.rsplit(".", 1)[1].lower() unique_filename = f"company_logo_{uuid.uuid4().hex[:8]}.{file_extension}" - + # Basic server-side validation: verify image type try: from PIL import Image + file.stream.seek(0) img = Image.open(file.stream) img.verify() file.stream.seek(0) except Exception: - flash(_('Invalid image file.'), 'error') - return redirect(url_for('admin.settings')) + flash(_("Invalid image file."), "error") + return redirect(url_for("admin.settings")) # Save file upload_folder = get_upload_folder() file_path = os.path.join(upload_folder, unique_filename) file.save(file_path) - + # Log successful save - current_app.logger.info(f'Logo saved successfully: {file_path}') - current_app.logger.info(f'File exists check: {os.path.exists(file_path)}') - current_app.logger.info(f'File size: {os.path.getsize(file_path) if os.path.exists(file_path) else "N/A"} bytes') - + current_app.logger.info(f"Logo saved successfully: {file_path}") + current_app.logger.info(f"File exists check: {os.path.exists(file_path)}") + current_app.logger.info( + f'File size: {os.path.getsize(file_path) if os.path.exists(file_path) else "N/A"} bytes' + ) + # Update settings settings_obj = Settings.get_settings() - + # Remove old logo if it exists if settings_obj.company_logo_filename: old_logo_path = os.path.join(upload_folder, settings_obj.company_logo_filename) @@ -1237,25 +1367,31 @@ def upload_logo(): os.remove(old_logo_path) except OSError: pass # Ignore errors when removing old file - + settings_obj.company_logo_filename = unique_filename - if not safe_commit('admin_upload_logo'): - flash(_('Could not save logo due to a database error. Please check server logs.'), 'error') - return redirect(url_for('admin.settings')) - - flash(_('Company logo uploaded successfully! You can see it in the "Current Company Logo" section above. It will appear on invoices and PDF documents.'), 'success') + if not safe_commit("admin_upload_logo"): + flash(_("Could not save logo due to a database error. Please check server logs."), "error") + return redirect(url_for("admin.settings")) + + flash( + _( + 'Company logo uploaded successfully! You can see it in the "Current Company Logo" section above. It will appear on invoices and PDF documents.' + ), + "success", + ) else: - flash(_('Invalid file type. Allowed types: PNG, JPG, JPEG, GIF, SVG, WEBP'), 'error') - - return redirect(url_for('admin.settings')) + flash(_("Invalid file type. Allowed types: PNG, JPG, JPEG, GIF, SVG, WEBP"), "error") + + return redirect(url_for("admin.settings")) + -@admin_bp.route('/admin/remove-logo', methods=['POST']) +@admin_bp.route("/admin/remove-logo", methods=["POST"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def remove_logo(): """Remove company logo""" settings_obj = Settings.get_settings() - + if settings_obj.company_logo_filename: # Remove file from filesystem logo_path = settings_obj.get_logo_path() @@ -1264,20 +1400,21 @@ def remove_logo(): os.remove(logo_path) except OSError: pass # Ignore errors when removing file - + # Clear filename from database - settings_obj.company_logo_filename = '' - if not safe_commit('admin_remove_logo'): - flash(_('Could not remove logo due to a database error. Please check server logs.'), 'error') - return redirect(url_for('admin.settings')) - flash(_('Company logo removed successfully. Upload a new logo in the section below if needed.'), 'success') + settings_obj.company_logo_filename = "" + if not safe_commit("admin_remove_logo"): + flash(_("Could not remove logo due to a database error. Please check server logs."), "error") + return redirect(url_for("admin.settings")) + flash(_("Company logo removed successfully. Upload a new logo in the section below if needed."), "success") else: - flash(_('No logo to remove'), 'info') - - return redirect(url_for('admin.settings')) + flash(_("No logo to remove"), "info") + + return redirect(url_for("admin.settings")) + # Public route to serve uploaded logos from the static uploads directory -@admin_bp.route('/uploads/logos/') +@admin_bp.route("/uploads/logos/") def serve_uploaded_logo(filename): """Serve company logo files stored under static/uploads/logos. This route is intentionally public so logos render on unauthenticated pages @@ -1286,166 +1423,174 @@ def serve_uploaded_logo(filename): try: upload_folder = get_upload_folder() file_path = os.path.join(upload_folder, filename) - + if not os.path.exists(file_path): - current_app.logger.error(f'Logo file not found: {file_path}') - return 'Logo file not found', 404 - + current_app.logger.error(f"Logo file not found: {file_path}") + return "Logo file not found", 404 + return send_from_directory(upload_folder, filename) except Exception as e: - current_app.logger.error(f'Error serving logo {filename}: {str(e)}') - return 'Error serving logo', 500 + current_app.logger.error(f"Error serving logo {filename}: {str(e)}") + return "Error serving logo", 500 + -@admin_bp.route('/admin/backups') +@admin_bp.route("/admin/backups") @login_required -@admin_or_permission_required('manage_backups') +@admin_or_permission_required("manage_backups") def backups_management(): """Backups management page""" # Get list of existing backups - backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, "..")), "backups") backups = [] - + if os.path.exists(backups_dir): for filename in os.listdir(backups_dir): - if filename.endswith('.zip') and not filename.startswith('restore_'): + if filename.endswith(".zip") and not filename.startswith("restore_"): filepath = os.path.join(backups_dir, filename) stat = os.stat(filepath) - backups.append({ - 'filename': filename, - 'size': stat.st_size, - 'created': datetime.fromtimestamp(stat.st_mtime), - 'size_mb': round(stat.st_size / (1024 * 1024), 2) - }) - + backups.append( + { + "filename": filename, + "size": stat.st_size, + "created": datetime.fromtimestamp(stat.st_mtime), + "size_mb": round(stat.st_size / (1024 * 1024), 2), + } + ) + # Sort by creation date (newest first) - backups.sort(key=lambda x: x['created'], reverse=True) - - return render_template('admin/backups.html', backups=backups) + backups.sort(key=lambda x: x["created"], reverse=True) + + return render_template("admin/backups.html", backups=backups) -@admin_bp.route('/admin/backup/create', methods=['POST']) +@admin_bp.route("/admin/backup/create", methods=["POST"]) @login_required -@admin_or_permission_required('manage_backups') +@admin_or_permission_required("manage_backups") def create_backup_manual(): """Create manual backup and return the archive for download.""" try: archive_path = create_backup(current_app) if not archive_path or not os.path.exists(archive_path): - flash(_('Backup failed: archive not created'), 'error') - return redirect(url_for('admin.backups_management')) + flash(_("Backup failed: archive not created"), "error") + return redirect(url_for("admin.backups_management")) # Stream file to user return send_file(archive_path, as_attachment=True) except Exception as e: - flash(_('Backup failed: %(error)s', error=str(e)), 'error') - return redirect(url_for('admin.backups_management')) + flash(_("Backup failed: %(error)s", error=str(e)), "error") + return redirect(url_for("admin.backups_management")) -@admin_bp.route('/admin/backup/download/') +@admin_bp.route("/admin/backup/download/") @login_required -@admin_or_permission_required('manage_backups') +@admin_or_permission_required("manage_backups") def download_backup(filename): """Download an existing backup file""" # Security: only allow downloading .zip files, no path traversal filename = secure_filename(filename) - if not filename.endswith('.zip'): - flash(_('Invalid file type'), 'error') - return redirect(url_for('admin.backups_management')) - - backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') + if not filename.endswith(".zip"): + flash(_("Invalid file type"), "error") + return redirect(url_for("admin.backups_management")) + + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, "..")), "backups") filepath = os.path.join(backups_dir, filename) - + if not os.path.exists(filepath): - flash(_('Backup file not found'), 'error') - return redirect(url_for('admin.backups_management')) - + flash(_("Backup file not found"), "error") + return redirect(url_for("admin.backups_management")) + return send_file(filepath, as_attachment=True) -@admin_bp.route('/admin/backup/delete/', methods=['POST']) +@admin_bp.route("/admin/backup/delete/", methods=["POST"]) @login_required -@admin_or_permission_required('manage_backups') +@admin_or_permission_required("manage_backups") def delete_backup(filename): """Delete a backup file""" # Security: only allow deleting .zip files, no path traversal filename = secure_filename(filename) - if not filename.endswith('.zip'): - flash(_('Invalid file type'), 'error') - return redirect(url_for('admin.backups_management')) - - backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') + if not filename.endswith(".zip"): + flash(_("Invalid file type"), "error") + return redirect(url_for("admin.backups_management")) + + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, "..")), "backups") filepath = os.path.join(backups_dir, filename) - + try: if os.path.exists(filepath): os.remove(filepath) - flash(_('Backup "%(filename)s" deleted successfully', filename=filename), 'success') + flash(_('Backup "%(filename)s" deleted successfully', filename=filename), "success") else: - flash(_('Backup file not found'), 'error') + flash(_("Backup file not found"), "error") except Exception as e: - flash(_('Failed to delete backup: %(error)s', error=str(e)), 'error') - - return redirect(url_for('admin.backups_management')) + flash(_("Failed to delete backup: %(error)s", error=str(e)), "error") -@admin_bp.route('/admin/restore', methods=['GET', 'POST']) -@admin_bp.route('/admin/restore/', methods=['POST']) + return redirect(url_for("admin.backups_management")) + + +@admin_bp.route("/admin/restore", methods=["GET", "POST"]) +@admin_bp.route("/admin/restore/", methods=["POST"]) @limiter.limit("3 per minute", methods=["POST"]) # heavy operation @login_required -@admin_or_permission_required('manage_backups') +@admin_or_permission_required("manage_backups") def restore(filename=None): """Restore from an uploaded backup archive or existing backup file.""" - if request.method == 'POST': - backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') - + if request.method == "POST": + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, "..")), "backups") + # If restoring from an existing backup file if filename: filename = secure_filename(filename) - if not filename.lower().endswith('.zip'): - flash(_('Invalid file type. Please select a .zip backup archive.'), 'error') - return redirect(url_for('admin.backups_management')) + if not filename.lower().endswith(".zip"): + flash(_("Invalid file type. Please select a .zip backup archive."), "error") + return redirect(url_for("admin.backups_management")) temp_path = os.path.join(backups_dir, filename) if not os.path.exists(temp_path): - flash(_('Backup file not found.'), 'error') - return redirect(url_for('admin.backups_management')) + flash(_("Backup file not found."), "error") + return redirect(url_for("admin.backups_management")) # Copy to temp location for processing actual_restore_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{filename}") shutil.copy2(temp_path, actual_restore_path) temp_path = actual_restore_path # If uploading a new backup file - elif 'backup_file' in request.files and request.files['backup_file'].filename != '': - file = request.files['backup_file'] + elif "backup_file" in request.files and request.files["backup_file"].filename != "": + file = request.files["backup_file"] uploaded_filename = secure_filename(file.filename) - if not uploaded_filename.lower().endswith('.zip'): - flash(_('Invalid file type. Please upload a .zip backup archive.'), 'error') - return redirect(url_for('admin.restore')) + if not uploaded_filename.lower().endswith(".zip"): + flash(_("Invalid file type. Please upload a .zip backup archive."), "error") + return redirect(url_for("admin.restore")) # Save temporarily under project backups os.makedirs(backups_dir, exist_ok=True) temp_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{uploaded_filename}") file.save(temp_path) else: - flash(_('No backup file provided'), 'error') - return redirect(url_for('admin.restore')) + flash(_("No backup file provided"), "error") + return redirect(url_for("admin.restore")) # Initialize progress state token = uuid.uuid4().hex[:8] - RESTORE_PROGRESS[token] = {'status': 'starting', 'percent': 0, 'message': 'Queued'} + RESTORE_PROGRESS[token] = {"status": "starting", "percent": 0, "message": "Queued"} def progress_cb(label, percent): - RESTORE_PROGRESS[token] = {'status': 'running', 'percent': int(percent), 'message': label} + RESTORE_PROGRESS[token] = {"status": "running", "percent": int(percent), "message": label} # Capture the real Flask app object for use in a background thread app_obj = current_app._get_current_object() def _do_restore(): try: - RESTORE_PROGRESS[token] = {'status': 'running', 'percent': 5, 'message': 'Starting restore'} + RESTORE_PROGRESS[token] = {"status": "running", "percent": 5, "message": "Starting restore"} success, message = restore_backup(app_obj, temp_path, progress_callback=progress_cb) RESTORE_PROGRESS[token] = { - 'status': 'done' if success else 'error', - 'percent': 100 if success else RESTORE_PROGRESS[token].get('percent', 0), - 'message': message + "status": "done" if success else "error", + "percent": 100 if success else RESTORE_PROGRESS[token].get("percent", 0), + "message": message, } except Exception as e: - RESTORE_PROGRESS[token] = {'status': 'error', 'percent': RESTORE_PROGRESS[token].get('percent', 0), 'message': str(e)} + RESTORE_PROGRESS[token] = { + "status": "error", + "percent": RESTORE_PROGRESS[token].get("percent", 0), + "message": str(e), + } finally: try: os.remove(temp_path) @@ -1456,16 +1601,17 @@ def _do_restore(): t = threading.Thread(target=_do_restore, daemon=True) t.start() - flash(_('Restore started. You can monitor progress on this page.'), 'info') - return redirect(url_for('admin.restore', token=token)) + flash(_("Restore started. You can monitor progress on this page."), "info") + return redirect(url_for("admin.restore", token=token)) # GET - token = request.args.get('token') + token = request.args.get("token") progress = RESTORE_PROGRESS.get(token) if token else None - return render_template('admin/restore.html', progress=progress, token=token) + return render_template("admin/restore.html", progress=progress, token=token) -@admin_bp.route('/admin/system') + +@admin_bp.route("/admin/system") @login_required -@admin_or_permission_required('view_system_info') +@admin_or_permission_required("view_system_info") def system_info(): """Show system information""" # Get system statistics @@ -1473,116 +1619,127 @@ def system_info(): total_projects = Project.query.count() total_entries = TimeEntry.query.count() active_timers = TimeEntry.query.filter_by(end_time=None).count() - + # Get database size db_size_bytes = 0 try: engine = db.session.bind - dialect = engine.dialect.name if engine else '' - if dialect == 'sqlite': - db_size_bytes = db.session.execute( - text('SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()') - ).scalar() or 0 - elif dialect in ('postgresql', 'postgres'): - db_size_bytes = db.session.execute( - text('SELECT pg_database_size(current_database())') - ).scalar() or 0 + dialect = engine.dialect.name if engine else "" + if dialect == "sqlite": + db_size_bytes = ( + db.session.execute( + text("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()") + ).scalar() + or 0 + ) + elif dialect in ("postgresql", "postgres"): + db_size_bytes = db.session.execute(text("SELECT pg_database_size(current_database())")).scalar() or 0 else: db_size_bytes = 0 except Exception: db_size_bytes = 0 db_size_mb = round(db_size_bytes / (1024 * 1024), 2) if db_size_bytes else 0 - - return render_template('admin/system_info.html', - total_users=total_users, - total_projects=total_projects, - total_entries=total_entries, - active_timers=active_timers, - db_size_mb=db_size_mb) - -@admin_bp.route('/admin/oidc/debug') + + return render_template( + "admin/system_info.html", + total_users=total_users, + total_projects=total_projects, + total_entries=total_entries, + active_timers=active_timers, + db_size_mb=db_size_mb, + ) + + +@admin_bp.route("/admin/oidc/debug") @login_required -@admin_or_permission_required('manage_oidc') +@admin_or_permission_required("manage_oidc") def oidc_debug(): """OIDC Configuration Debug Dashboard""" from app.config import Config from app import oauth - + # Gather OIDC configuration oidc_config = { - 'enabled': False, - 'auth_method': getattr(Config, 'AUTH_METHOD', 'local'), - 'issuer': getattr(Config, 'OIDC_ISSUER', None), - 'client_id': getattr(Config, 'OIDC_CLIENT_ID', None), - 'client_secret_set': bool(getattr(Config, 'OIDC_CLIENT_SECRET', None)), - 'redirect_uri': getattr(Config, 'OIDC_REDIRECT_URI', None), - 'scopes': getattr(Config, 'OIDC_SCOPES', 'openid profile email'), - 'username_claim': getattr(Config, 'OIDC_USERNAME_CLAIM', 'preferred_username'), - 'email_claim': getattr(Config, 'OIDC_EMAIL_CLAIM', 'email'), - 'full_name_claim': getattr(Config, 'OIDC_FULL_NAME_CLAIM', 'name'), - 'groups_claim': getattr(Config, 'OIDC_GROUPS_CLAIM', 'groups'), - 'admin_group': getattr(Config, 'OIDC_ADMIN_GROUP', None), - 'admin_emails': getattr(Config, 'OIDC_ADMIN_EMAILS', []), - 'post_logout_redirect': getattr(Config, 'OIDC_POST_LOGOUT_REDIRECT_URI', None), + "enabled": False, + "auth_method": getattr(Config, "AUTH_METHOD", "local"), + "issuer": getattr(Config, "OIDC_ISSUER", None), + "client_id": getattr(Config, "OIDC_CLIENT_ID", None), + "client_secret_set": bool(getattr(Config, "OIDC_CLIENT_SECRET", None)), + "redirect_uri": getattr(Config, "OIDC_REDIRECT_URI", None), + "scopes": getattr(Config, "OIDC_SCOPES", "openid profile email"), + "username_claim": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"), + "email_claim": getattr(Config, "OIDC_EMAIL_CLAIM", "email"), + "full_name_claim": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"), + "groups_claim": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"), + "admin_group": getattr(Config, "OIDC_ADMIN_GROUP", None), + "admin_emails": getattr(Config, "OIDC_ADMIN_EMAILS", []), + "post_logout_redirect": getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", None), } - + # Check if OIDC is enabled - auth_method = (oidc_config['auth_method'] or 'local').strip().lower() - oidc_config['enabled'] = auth_method in ('oidc', 'both') - + auth_method = (oidc_config["auth_method"] or "local").strip().lower() + oidc_config["enabled"] = auth_method in ("oidc", "both") + # Try to get OIDC client metadata metadata = None metadata_error = None well_known_url = None - - if oidc_config['enabled'] and oidc_config['issuer']: + + if oidc_config["enabled"] and oidc_config["issuer"]: try: - client = oauth.create_client('oidc') + client = oauth.create_client("oidc") if client: metadata = client.load_server_metadata() well_known_url = f"{oidc_config['issuer'].rstrip('/')}/.well-known/openid-configuration" except Exception as e: metadata_error = str(e) - well_known_url = f"{oidc_config['issuer'].rstrip('/')}/.well-known/openid-configuration" if oidc_config['issuer'] else None - + well_known_url = ( + f"{oidc_config['issuer'].rstrip('/')}/.well-known/openid-configuration" + if oidc_config["issuer"] + else None + ) + # Get OIDC users from database oidc_users = [] try: - oidc_users = User.query.filter( - User.oidc_issuer.isnot(None), - User.oidc_sub.isnot(None) - ).order_by(User.last_login.desc()).all() + oidc_users = ( + User.query.filter(User.oidc_issuer.isnot(None), User.oidc_sub.isnot(None)) + .order_by(User.last_login.desc()) + .all() + ) except Exception: pass - - return render_template('admin/oidc_debug.html', - oidc_config=oidc_config, - metadata=metadata, - metadata_error=metadata_error, - well_known_url=well_known_url, - oidc_users=oidc_users) + + return render_template( + "admin/oidc_debug.html", + oidc_config=oidc_config, + metadata=metadata, + metadata_error=metadata_error, + well_known_url=well_known_url, + oidc_users=oidc_users, + ) -@admin_bp.route('/admin/oidc/test') +@admin_bp.route("/admin/oidc/test") @limiter.limit("10 per minute") @login_required -@admin_or_permission_required('manage_oidc') +@admin_or_permission_required("manage_oidc") def oidc_test(): """Test OIDC configuration by fetching discovery document""" from app.config import Config from app import oauth import requests - - auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower() - if auth_method not in ('oidc', 'both'): - flash(_('OIDC is not enabled. Set AUTH_METHOD to "oidc" or "both".'), 'warning') - return redirect(url_for('admin.oidc_debug')) - - issuer = getattr(Config, 'OIDC_ISSUER', None) + + auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() + if auth_method not in ("oidc", "both"): + flash(_('OIDC is not enabled. Set AUTH_METHOD to "oidc" or "both".'), "warning") + return redirect(url_for("admin.oidc_debug")) + + issuer = getattr(Config, "OIDC_ISSUER", None) if not issuer: - flash(_('OIDC_ISSUER is not configured'), 'error') - return redirect(url_for('admin.oidc_debug')) - + flash(_("OIDC_ISSUER is not configured"), "error") + return redirect(url_for("admin.oidc_debug")) + # Test 1: Check if discovery document is accessible well_known_url = f"{issuer.rstrip('/')}/.well-known/openid-configuration" try: @@ -1590,480 +1747,479 @@ def oidc_test(): response = requests.get(well_known_url, timeout=10) response.raise_for_status() discovery_doc = response.json() - flash(_('✓ Discovery document fetched successfully from %(url)s', url=well_known_url), 'success') - current_app.logger.info("OIDC Test: Discovery document retrieved, issuer=%s", discovery_doc.get('issuer')) + flash(_("✓ Discovery document fetched successfully from %(url)s", url=well_known_url), "success") + current_app.logger.info("OIDC Test: Discovery document retrieved, issuer=%s", discovery_doc.get("issuer")) except requests.exceptions.Timeout: - flash(_('✗ Timeout fetching discovery document from %(url)s', url=well_known_url), 'error') + flash(_("✗ Timeout fetching discovery document from %(url)s", url=well_known_url), "error") current_app.logger.error("OIDC Test: Timeout fetching discovery document") - return redirect(url_for('admin.oidc_debug')) + return redirect(url_for("admin.oidc_debug")) except requests.exceptions.RequestException as e: - flash(_('✗ Failed to fetch discovery document: %(error)s', error=str(e)), 'error') + flash(_("✗ Failed to fetch discovery document: %(error)s", error=str(e)), "error") current_app.logger.error("OIDC Test: Failed to fetch discovery document: %s", str(e)) - return redirect(url_for('admin.oidc_debug')) + return redirect(url_for("admin.oidc_debug")) except Exception as e: - flash(_('✗ Unexpected error: %(error)s', error=str(e)), 'error') + flash(_("✗ Unexpected error: %(error)s", error=str(e)), "error") current_app.logger.error("OIDC Test: Unexpected error: %s", str(e)) - return redirect(url_for('admin.oidc_debug')) - + return redirect(url_for("admin.oidc_debug")) + # Test 2: Check if OAuth client is registered try: - client = oauth.create_client('oidc') + client = oauth.create_client("oidc") if client: - flash(_('✓ OAuth client is registered in application'), 'success') + flash(_("✓ OAuth client is registered in application"), "success") current_app.logger.info("OIDC Test: OAuth client registered") else: - flash(_('✗ OAuth client is not registered'), 'error') + flash(_("✗ OAuth client is not registered"), "error") current_app.logger.error("OIDC Test: OAuth client not registered") except Exception as e: - flash(_('✗ Failed to create OAuth client: %(error)s', error=str(e)), 'error') + flash(_("✗ Failed to create OAuth client: %(error)s", error=str(e)), "error") current_app.logger.error("OIDC Test: Failed to create OAuth client: %s", str(e)) - + # Test 3: Verify required endpoints are present - required_endpoints = ['authorization_endpoint', 'token_endpoint', 'userinfo_endpoint'] + required_endpoints = ["authorization_endpoint", "token_endpoint", "userinfo_endpoint"] for endpoint in required_endpoints: if endpoint in discovery_doc: - flash(_('✓ %(endpoint)s: %(url)s', endpoint=endpoint, url=discovery_doc[endpoint]), 'info') + flash(_("✓ %(endpoint)s: %(url)s", endpoint=endpoint, url=discovery_doc[endpoint]), "info") else: - flash(_('✗ Missing %(endpoint)s in discovery document', endpoint=endpoint), 'warning') - + flash(_("✗ Missing %(endpoint)s in discovery document", endpoint=endpoint), "warning") + # Test 4: Check supported scopes - supported_scopes = discovery_doc.get('scopes_supported', []) - requested_scopes = getattr(Config, 'OIDC_SCOPES', 'openid profile email').split() + supported_scopes = discovery_doc.get("scopes_supported", []) + requested_scopes = getattr(Config, "OIDC_SCOPES", "openid profile email").split() for scope in requested_scopes: if scope in supported_scopes: - flash(_('✓ Scope "%(scope)s" is supported by provider', scope=scope), 'info') + flash(_('✓ Scope "%(scope)s" is supported by provider', scope=scope), "info") else: - flash(_('⚠ Scope "%(scope)s" may not be supported by provider (supported: %(supported)s)', scope=scope, supported=', '.join(supported_scopes)), 'warning') - + flash( + _( + '⚠ Scope "%(scope)s" may not be supported by provider (supported: %(supported)s)', + scope=scope, + supported=", ".join(supported_scopes), + ), + "warning", + ) + # Test 5: Check claims - supported_claims = discovery_doc.get('claims_supported', []) + supported_claims = discovery_doc.get("claims_supported", []) if supported_claims: - flash(_('ℹ Provider supports claims: %(claims)s', claims=', '.join(supported_claims)), 'info') - + flash(_("ℹ Provider supports claims: %(claims)s", claims=", ".join(supported_claims)), "info") + # Check if configured claims are supported claim_checks = { - 'username': getattr(Config, 'OIDC_USERNAME_CLAIM', 'preferred_username'), - 'email': getattr(Config, 'OIDC_EMAIL_CLAIM', 'email'), - 'full_name': getattr(Config, 'OIDC_FULL_NAME_CLAIM', 'name'), - 'groups': getattr(Config, 'OIDC_GROUPS_CLAIM', 'groups'), + "username": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"), + "email": getattr(Config, "OIDC_EMAIL_CLAIM", "email"), + "full_name": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"), + "groups": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"), } - + for claim_type, claim_name in claim_checks.items(): if claim_name in supported_claims: - flash(_('✓ Configured %(claim_type)s claim "%(claim_name)s" is supported', claim_type=claim_type, claim_name=claim_name), 'info') + flash( + _( + '✓ Configured %(claim_type)s claim "%(claim_name)s" is supported', + claim_type=claim_type, + claim_name=claim_name, + ), + "info", + ) else: - flash(_('⚠ Configured %(claim_type)s claim "%(claim_name)s" not in supported claims list (may still work)', claim_type=claim_type, claim_name=claim_name), 'warning') - - flash(_('OIDC configuration test completed'), 'info') - return redirect(url_for('admin.oidc_debug')) + flash( + _( + '⚠ Configured %(claim_type)s claim "%(claim_name)s" not in supported claims list (may still work)', + claim_type=claim_type, + claim_name=claim_name, + ), + "warning", + ) + + flash(_("OIDC configuration test completed"), "info") + return redirect(url_for("admin.oidc_debug")) -@admin_bp.route('/admin/oidc/user/') +@admin_bp.route("/admin/oidc/user/") @login_required -@admin_or_permission_required('view_users') +@admin_or_permission_required("view_users") def oidc_user_detail(user_id): """View OIDC details for a specific user""" user = User.query.get_or_404(user_id) - - return render_template('admin/oidc_user_detail.html', user=user) + + return render_template("admin/oidc_user_detail.html", user=user) # ==================== API Token Management ==================== -@admin_bp.route('/admin/api-tokens') + +@admin_bp.route("/admin/api-tokens") @login_required @admin_required def api_tokens(): """API tokens management page""" from app.models import ApiToken - + tokens = ApiToken.query.order_by(ApiToken.created_at.desc()).all() users = User.query.filter_by(is_active=True).order_by(User.username).all() - - return render_template('admin/api_tokens.html', - tokens=tokens, - users=users, - now=datetime.utcnow()) + + return render_template("admin/api_tokens.html", tokens=tokens, users=users, now=datetime.utcnow()) -@admin_bp.route('/admin/api-tokens', methods=['POST']) +@admin_bp.route("/admin/api-tokens", methods=["POST"]) @login_required @admin_required def create_api_token(): """Create a new API token""" from app.models import ApiToken - + data = request.get_json() or {} - + # Validate input - if not data.get('name'): - return jsonify({'error': 'Token name is required'}), 400 - if not data.get('user_id'): - return jsonify({'error': 'User ID is required'}), 400 - if not data.get('scopes'): - return jsonify({'error': 'At least one scope is required'}), 400 - + if not data.get("name"): + return jsonify({"error": "Token name is required"}), 400 + if not data.get("user_id"): + return jsonify({"error": "User ID is required"}), 400 + if not data.get("scopes"): + return jsonify({"error": "At least one scope is required"}), 400 + # Verify user exists - user = User.query.get(data['user_id']) + user = User.query.get(data["user_id"]) if not user: - return jsonify({'error': 'Invalid user'}), 400 - + return jsonify({"error": "Invalid user"}), 400 + # Create token try: api_token, plain_token = ApiToken.create_token( - user_id=data['user_id'], - name=data['name'], - description=data.get('description', ''), - scopes=data['scopes'], - expires_days=data.get('expires_days') + user_id=data["user_id"], + name=data["name"], + description=data.get("description", ""), + scopes=data["scopes"], + expires_days=data.get("expires_days"), ) - + db.session.add(api_token) db.session.commit() - + current_app.logger.info( f"API token '{data['name']}' created for user {user.username} by {current_user.username}" ) - - return jsonify({ - 'message': 'API token created successfully', - 'token': plain_token, - 'token_id': api_token.id - }), 201 - + + return ( + jsonify({"message": "API token created successfully", "token": plain_token, "token_id": api_token.id}), + 201, + ) + except Exception as e: db.session.rollback() current_app.logger.error(f"Failed to create API token: {e}") - return jsonify({'error': 'Failed to create token'}), 500 + return jsonify({"error": "Failed to create token"}), 500 -@admin_bp.route('/admin/api-tokens//toggle', methods=['POST']) +@admin_bp.route("/admin/api-tokens//toggle", methods=["POST"]) @login_required @admin_required def toggle_api_token(token_id): """Toggle API token active status""" from app.models import ApiToken - + token = ApiToken.query.get_or_404(token_id) token.is_active = not token.is_active - + try: db.session.commit() - status = 'activated' if token.is_active else 'deactivated' - current_app.logger.info( - f"API token '{token.name}' {status} by {current_user.username}" - ) - return jsonify({'message': f'Token {status} successfully'}) + status = "activated" if token.is_active else "deactivated" + current_app.logger.info(f"API token '{token.name}' {status} by {current_user.username}") + return jsonify({"message": f"Token {status} successfully"}) except Exception as e: db.session.rollback() current_app.logger.error(f"Failed to toggle API token: {e}") - return jsonify({'error': 'Failed to update token'}), 500 + return jsonify({"error": "Failed to update token"}), 500 -@admin_bp.route('/admin/api-tokens/', methods=['DELETE']) +@admin_bp.route("/admin/api-tokens/", methods=["DELETE"]) @login_required @admin_required def delete_api_token(token_id): """Delete an API token""" from app.models import ApiToken - + token = ApiToken.query.get_or_404(token_id) token_name = token.name - + try: db.session.delete(token) db.session.commit() - current_app.logger.info( - f"API token '{token_name}' deleted by {current_user.username}" - ) - return jsonify({'message': 'Token deleted successfully'}) + current_app.logger.info(f"API token '{token_name}' deleted by {current_user.username}") + return jsonify({"message": "Token deleted successfully"}) except Exception as e: db.session.rollback() current_app.logger.error(f"Failed to delete API token: {e}") - return jsonify({'error': 'Failed to delete token'}), 500 + return jsonify({"error": "Failed to delete token"}), 500 # ==================== Email Configuration Management ==================== -@admin_bp.route('/admin/email') + +@admin_bp.route("/admin/email") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def email_support(): """Email configuration and testing page""" from app.utils.email import check_email_configuration - + # Get email configuration status email_status = check_email_configuration() - + # Log dashboard access app_module.log_event("admin.email_support_viewed", user_id=current_user.id) app_module.track_event(current_user.id, "admin.email_support_viewed", {}) - - return render_template('admin/email_support.html', - email_status=email_status) + + return render_template("admin/email_support.html", email_status=email_status) -@admin_bp.route('/admin/email/test', methods=['POST']) +@admin_bp.route("/admin/email/test", methods=["POST"]) @limiter.limit("5 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def test_email(): """Send a test email""" from app.utils.email import send_test_email - + data = request.get_json() or {} - recipient = data.get('recipient') - + recipient = data.get("recipient") + if not recipient: current_app.logger.warning(f"[EMAIL TEST API] No recipient provided by user {current_user.username}") - return jsonify({'success': False, 'message': 'Recipient email is required'}), 400 - + return jsonify({"success": False, "message": "Recipient email is required"}), 400 + current_app.logger.info(f"[EMAIL TEST API] Test email request from user {current_user.username} to {recipient}") - + # Send test email - sender_name = current_user.username or 'TimeTracker Admin' + sender_name = current_user.username or "TimeTracker Admin" success, message = send_test_email(recipient, sender_name) - + # Log the test current_app.logger.info(f"[EMAIL TEST API] Result: {'SUCCESS' if success else 'FAILED'} - {message}") - app_module.log_event("admin.email_test_sent", - user_id=current_user.id, - recipient=recipient, - success=success) - app_module.track_event(current_user.id, "admin.email_test_sent", { - 'success': success, - 'configured': success - }) - + app_module.log_event("admin.email_test_sent", user_id=current_user.id, recipient=recipient, success=success) + app_module.track_event(current_user.id, "admin.email_test_sent", {"success": success, "configured": success}) + if success: - return jsonify({'success': True, 'message': message}), 200 + return jsonify({"success": True, "message": message}), 200 else: - return jsonify({'success': False, 'message': message}), 500 + return jsonify({"success": False, "message": message}), 500 -@admin_bp.route('/admin/email/config-status', methods=['GET']) +@admin_bp.route("/admin/email/config-status", methods=["GET"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def email_config_status(): """Get current email configuration status (for AJAX polling)""" from app.utils.email import check_email_configuration - + email_status = check_email_configuration() return jsonify(email_status), 200 -@admin_bp.route('/admin/email/configure', methods=['POST']) +@admin_bp.route("/admin/email/configure", methods=["POST"]) @limiter.limit("10 per minute") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def save_email_config(): """Save email configuration to database""" from app.utils.email import reload_mail_config - + data = request.get_json() or {} - + current_app.logger.info(f"[EMAIL CONFIG] Saving email configuration by user {current_user.username}") - + # Get settings settings = Settings.get_settings() - + # Update email configuration - settings.mail_enabled = data.get('enabled', False) - settings.mail_server = data.get('server', '').strip() - settings.mail_port = int(data.get('port', 587)) - settings.mail_use_tls = data.get('use_tls', True) - settings.mail_use_ssl = data.get('use_ssl', False) - settings.mail_username = data.get('username', '').strip() - + settings.mail_enabled = data.get("enabled", False) + settings.mail_server = data.get("server", "").strip() + settings.mail_port = int(data.get("port", 587)) + settings.mail_use_tls = data.get("use_tls", True) + settings.mail_use_ssl = data.get("use_ssl", False) + settings.mail_username = data.get("username", "").strip() + # Only update password if provided (non-empty) - password = data.get('password', '').strip() + password = data.get("password", "").strip() if password: settings.mail_password = password current_app.logger.info("[EMAIL CONFIG] Password updated") - - settings.mail_default_sender = data.get('default_sender', '').strip() - - current_app.logger.info(f"[EMAIL CONFIG] Settings: enabled={settings.mail_enabled}, " - f"server={settings.mail_server}:{settings.mail_port}, " - f"tls={settings.mail_use_tls}, ssl={settings.mail_use_ssl}") - + + settings.mail_default_sender = data.get("default_sender", "").strip() + + current_app.logger.info( + f"[EMAIL CONFIG] Settings: enabled={settings.mail_enabled}, " + f"server={settings.mail_server}:{settings.mail_port}, " + f"tls={settings.mail_use_tls}, ssl={settings.mail_use_ssl}" + ) + # Validate if settings.mail_enabled and not settings.mail_server: current_app.logger.warning("[EMAIL CONFIG] Validation failed: mail server required") - return jsonify({ - 'success': False, - 'message': 'Mail server is required when email is enabled' - }), 400 - + return jsonify({"success": False, "message": "Mail server is required when email is enabled"}), 400 + if settings.mail_use_tls and settings.mail_use_ssl: current_app.logger.warning("[EMAIL CONFIG] Validation failed: both TLS and SSL enabled") - return jsonify({ - 'success': False, - 'message': 'Cannot use both TLS and SSL. Please choose one.' - }), 400 - + return jsonify({"success": False, "message": "Cannot use both TLS and SSL. Please choose one."}), 400 + # Save to database - if not safe_commit('admin_save_email_config'): + if not safe_commit("admin_save_email_config"): current_app.logger.error("[EMAIL CONFIG] Failed to save to database") - return jsonify({ - 'success': False, - 'message': 'Failed to save email configuration to database' - }), 500 - + return jsonify({"success": False, "message": "Failed to save email configuration to database"}), 500 + current_app.logger.info("[EMAIL CONFIG] ✓ Configuration saved to database") - + # Reload mail configuration if settings.mail_enabled: current_app.logger.info("[EMAIL CONFIG] Reloading mail configuration...") reload_result = reload_mail_config(current_app._get_current_object()) current_app.logger.info(f"[EMAIL CONFIG] Mail config reload: {'SUCCESS' if reload_result else 'FAILED'}") - + # Log the change - app_module.log_event("admin.email_config_saved", - user_id=current_user.id, - enabled=settings.mail_enabled) - app_module.track_event(current_user.id, "admin.email_config_saved", { - 'enabled': settings.mail_enabled, - 'source': 'database' - }) - + app_module.log_event("admin.email_config_saved", user_id=current_user.id, enabled=settings.mail_enabled) + app_module.track_event( + current_user.id, "admin.email_config_saved", {"enabled": settings.mail_enabled, "source": "database"} + ) + current_app.logger.info("[EMAIL CONFIG] ✓ Email configuration update complete") - - return jsonify({ - 'success': True, - 'message': 'Email configuration saved successfully' - }), 200 + + return jsonify({"success": True, "message": "Email configuration saved successfully"}), 200 -@admin_bp.route('/admin/email/get-config', methods=['GET']) +@admin_bp.route("/admin/email/get-config", methods=["GET"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def get_email_config(): """Get current email configuration from database""" settings = Settings.get_settings() - - return jsonify({ - 'enabled': settings.mail_enabled, - 'server': settings.mail_server or '', - 'port': settings.mail_port or 587, - 'use_tls': settings.mail_use_tls if settings.mail_use_tls is not None else True, - 'use_ssl': settings.mail_use_ssl if settings.mail_use_ssl is not None else False, - 'username': settings.mail_username or '', - 'password_set': bool(settings.mail_password), - 'default_sender': settings.mail_default_sender or '' - }), 200 + + return ( + jsonify( + { + "enabled": settings.mail_enabled, + "server": settings.mail_server or "", + "port": settings.mail_port or 587, + "use_tls": settings.mail_use_tls if settings.mail_use_tls is not None else True, + "use_ssl": settings.mail_use_ssl if settings.mail_use_ssl is not None else False, + "username": settings.mail_username or "", + "password_set": bool(settings.mail_password), + "default_sender": settings.mail_default_sender or "", + } + ), + 200, + ) # ==================== Email Template Management ==================== -@admin_bp.route('/admin/email-templates') + +@admin_bp.route("/admin/email-templates") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def list_email_templates(): """List all email templates""" from app.models import InvoiceTemplate - + templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all() - - return render_template('admin/email_templates/list.html', templates=templates) + return render_template("admin/email_templates/list.html", templates=templates) -@admin_bp.route('/admin/email-templates/create', methods=['GET', 'POST']) + +@admin_bp.route("/admin/email-templates/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def create_email_template(): """Create a new email template""" from app.models import InvoiceTemplate - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - html = request.form.get('html', '').strip() - css = request.form.get('css', '').strip() - is_default = request.form.get('is_default') == 'on' - + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + html = request.form.get("html", "").strip() + css = request.form.get("css", "").strip() + is_default = request.form.get("is_default") == "on" + # Validate if not name: - flash(_('Template name is required'), 'error') - return render_template('admin/email_templates/create.html') - + flash(_("Template name is required"), "error") + return render_template("admin/email_templates/create.html") + # Check for duplicate name existing = InvoiceTemplate.query.filter_by(name=name).first() if existing: - flash(_('A template with this name already exists'), 'error') - return render_template('admin/email_templates/create.html', - name=name, description=description, html=html, css=css) - + flash(_("A template with this name already exists"), "error") + return render_template( + "admin/email_templates/create.html", name=name, description=description, html=html, css=css + ) + # If setting as default, unset other defaults if is_default: InvoiceTemplate.query.update({InvoiceTemplate.is_default: False}) - + # Create template template = InvoiceTemplate( name=name, description=description if description else None, html=html if html else None, css=css if css else None, - is_default=is_default + is_default=is_default, ) - + db.session.add(template) - if not safe_commit('create_email_template', {'name': name}): - flash(_('Could not create email template due to a database error.'), 'error') - return render_template('admin/email_templates/create.html') - - flash(_('Email template created successfully'), 'success') - return redirect(url_for('admin.list_email_templates')) - - return render_template('admin/email_templates/create.html') + if not safe_commit("create_email_template", {"name": name}): + flash(_("Could not create email template due to a database error."), "error") + return render_template("admin/email_templates/create.html") + flash(_("Email template created successfully"), "success") + return redirect(url_for("admin.list_email_templates")) -@admin_bp.route('/admin/email-templates/') + return render_template("admin/email_templates/create.html") + + +@admin_bp.route("/admin/email-templates/") @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def view_email_template(template_id): """View email template details""" from app.models import InvoiceTemplate - + template = InvoiceTemplate.query.get_or_404(template_id) - - return render_template('admin/email_templates/view.html', template=template) + return render_template("admin/email_templates/view.html", template=template) -@admin_bp.route('/admin/email-templates//edit', methods=['GET', 'POST']) + +@admin_bp.route("/admin/email-templates//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def edit_email_template(template_id): """Edit email template""" from app.models import InvoiceTemplate - + template = InvoiceTemplate.query.get_or_404(template_id) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - html = request.form.get('html', '').strip() - css = request.form.get('css', '').strip() - is_default = request.form.get('is_default') == 'on' - + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + html = request.form.get("html", "").strip() + css = request.form.get("css", "").strip() + is_default = request.form.get("is_default") == "on" + # Validate if not name: - flash(_('Template name is required'), 'error') - return render_template('admin/email_templates/edit.html', template=template) - + flash(_("Template name is required"), "error") + return render_template("admin/email_templates/edit.html", template=template) + # Check for duplicate name (excluding current template) - existing = InvoiceTemplate.query.filter( - InvoiceTemplate.name == name, - InvoiceTemplate.id != template_id - ).first() + existing = InvoiceTemplate.query.filter(InvoiceTemplate.name == name, InvoiceTemplate.id != template_id).first() if existing: - flash(_('A template with this name already exists'), 'error') - return render_template('admin/email_templates/edit.html', template=template) - + flash(_("A template with this name already exists"), "error") + return render_template("admin/email_templates/edit.html", template=template) + # If setting as default, unset other defaults if is_default: InvoiceTemplate.query.filter(InvoiceTemplate.id != template_id).update({InvoiceTemplate.is_default: False}) - + # Update template template.name = name template.description = description if description else None @@ -2071,36 +2227,36 @@ def edit_email_template(template_id): template.css = css if css else None template.is_default = is_default template.updated_at = datetime.utcnow() - - if not safe_commit('edit_email_template', {'template_id': template_id}): - flash(_('Could not update email template due to a database error.'), 'error') - return render_template('admin/email_templates/edit.html', template=template) - - flash(_('Email template updated successfully'), 'success') - return redirect(url_for('admin.view_email_template', template_id=template_id)) - - return render_template('admin/email_templates/edit.html', template=template) - - -@admin_bp.route('/admin/email-templates//delete', methods=['POST']) + + if not safe_commit("edit_email_template", {"template_id": template_id}): + flash(_("Could not update email template due to a database error."), "error") + return render_template("admin/email_templates/edit.html", template=template) + + flash(_("Email template updated successfully"), "success") + return redirect(url_for("admin.view_email_template", template_id=template_id)) + + return render_template("admin/email_templates/edit.html", template=template) + + +@admin_bp.route("/admin/email-templates//delete", methods=["POST"]) @login_required -@admin_or_permission_required('manage_settings') +@admin_or_permission_required("manage_settings") def delete_email_template(template_id): """Delete email template""" from app.models import InvoiceTemplate - + template = InvoiceTemplate.query.get_or_404(template_id) template_name = template.name - + # Check if template is in use if template.invoices.count() > 0 or template.recurring_invoices.count() > 0: - flash(_('Cannot delete template that is in use by invoices or recurring invoices'), 'error') - return redirect(url_for('admin.list_email_templates')) - + flash(_("Cannot delete template that is in use by invoices or recurring invoices"), "error") + return redirect(url_for("admin.list_email_templates")) + db.session.delete(template) - if not safe_commit('delete_email_template', {'template_id': template_id}): - flash(_('Could not delete email template due to a database error.'), 'error') + if not safe_commit("delete_email_template", {"template_id": template_id}): + flash(_("Could not delete email template due to a database error."), "error") else: - flash(_('Email template "%(name)s" deleted successfully', name=template_name), 'success') - - return redirect(url_for('admin.list_email_templates')) + flash(_('Email template "%(name)s" deleted successfully', name=template_name), "success") + + return redirect(url_for("admin.list_email_templates")) diff --git a/app/routes/analytics.py b/app/routes/analytics.py index 75d90d74..ede350ab 100644 --- a/app/routes/analytics.py +++ b/app/routes/analytics.py @@ -6,233 +6,255 @@ from sqlalchemy import func, extract, case import calendar -analytics_bp = Blueprint('analytics', __name__) +analytics_bp = Blueprint("analytics", __name__) -@analytics_bp.route('/analytics') + +@analytics_bp.route("/analytics") @login_required def analytics_dashboard(): """Main analytics dashboard with charts""" # Check if user agent indicates mobile device - user_agent = request.headers.get('User-Agent', '').lower() - is_mobile = any(device in user_agent for device in ['mobile', 'android', 'iphone', 'ipad']) - + user_agent = request.headers.get("User-Agent", "").lower() + is_mobile = any(device in user_agent for device in ["mobile", "android", "iphone", "ipad"]) + # Check for legacy/simple dashboard query parameter - use_legacy = request.args.get('legacy', '').lower() == 'true' - + use_legacy = request.args.get("legacy", "").lower() == "true" + if is_mobile: - return render_template('analytics/mobile_dashboard.html') + return render_template("analytics/mobile_dashboard.html") elif use_legacy: - return render_template('analytics/dashboard.html') + return render_template("analytics/dashboard.html") else: - return render_template('analytics/dashboard_improved.html') + return render_template("analytics/dashboard_improved.html") -@analytics_bp.route('/api/analytics/hours-by-day') + +@analytics_bp.route("/api/analytics/hours-by-day") @login_required def hours_by_day(): """Get hours worked per day for the last 30 days""" try: - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) except (ValueError, TypeError): - return jsonify({'error': 'Invalid days parameter'}), 400 - + return jsonify({"error": "Invalid days parameter"}), 400 + end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Build query based on user permissions query = db.session.query( - func.date(TimeEntry.start_time).label('date'), - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date - ) - + func.date(TimeEntry.start_time).label("date"), func.sum(TimeEntry.duration_seconds).label("total_seconds") + ).filter(TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date) + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - + results = query.group_by(func.date(TimeEntry.start_time)).all() - + # Create date range and fill missing dates with 0 date_data = {} current_date = start_date while current_date <= end_date: - date_data[current_date.strftime('%Y-%m-%d')] = 0 + date_data[current_date.strftime("%Y-%m-%d")] = 0 current_date += timedelta(days=1) - + # Fill in actual data for date_str, total_seconds in results: if date_str: # Handle both string and date object returns from different databases if isinstance(date_str, str): formatted_date = date_str - elif hasattr(date_str, 'strftime'): - formatted_date = date_str.strftime('%Y-%m-%d') + elif hasattr(date_str, "strftime"): + formatted_date = date_str.strftime("%Y-%m-%d") else: # Skip if we can't format the date continue if total_seconds is None: total_seconds = 0 date_data[formatted_date] = round(total_seconds / 3600, 2) - - return jsonify({ - 'labels': list(date_data.keys()), - 'datasets': [{ - 'label': 'Hours Worked', - 'data': list(date_data.values()), - 'borderColor': '#3b82f6', - 'backgroundColor': 'rgba(59, 130, 246, 0.1)', - 'tension': 0.4, - 'fill': True - }] - }) - -@analytics_bp.route('/api/analytics/hours-by-project') + + return jsonify( + { + "labels": list(date_data.keys()), + "datasets": [ + { + "label": "Hours Worked", + "data": list(date_data.values()), + "borderColor": "#3b82f6", + "backgroundColor": "rgba(59, 130, 246, 0.1)", + "tension": 0.4, + "fill": True, + } + ], + } + ) + + +@analytics_bp.route("/api/analytics/hours-by-project") @login_required def hours_by_project(): """Get total hours per project""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - - query = db.session.query( - Project.name, - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).join(TimeEntry).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date, - Project.status == 'active' + + query = ( + db.session.query(Project.name, func.sum(TimeEntry.duration_seconds).label("total_seconds")) + .join(TimeEntry) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + Project.status == "active", + ) ) - + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - + results = query.group_by(Project.name).order_by(func.sum(TimeEntry.duration_seconds).desc()).limit(10).all() - + labels = [project for project, _ in results] data = [round(seconds / 3600, 2) for _, seconds in results] - + # Generate colors for each project colors = [ - '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', - '#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1' + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#06b6d4", + "#84cc16", + "#f97316", + "#ec4899", + "#6366f1", ] - - return jsonify({ - 'labels': labels, - 'datasets': [{ - 'label': 'Hours', - 'data': data, - 'backgroundColor': colors[:len(labels)], - 'borderColor': colors[:len(labels)], - 'borderWidth': 1 - }] - }) - -@analytics_bp.route('/api/analytics/hours-by-user') + + return jsonify( + { + "labels": labels, + "datasets": [ + { + "label": "Hours", + "data": data, + "backgroundColor": colors[: len(labels)], + "borderColor": colors[: len(labels)], + "borderWidth": 1, + } + ], + } + ) + + +@analytics_bp.route("/api/analytics/hours-by-user") @login_required def hours_by_user(): """Get total hours per user (admin only)""" if not current_user.is_admin: - return jsonify({'error': 'Unauthorized'}), 403 - - days = int(request.args.get('days', 30)) + return jsonify({"error": "Unauthorized"}), 403 + + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - - results = db.session.query( - User.username, - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).join(TimeEntry).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date, - User.is_active == True - ).group_by(User.username).order_by(func.sum(TimeEntry.duration_seconds).desc()).all() - + + results = ( + db.session.query(User.username, func.sum(TimeEntry.duration_seconds).label("total_seconds")) + .join(TimeEntry) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + User.is_active == True, + ) + .group_by(User.username) + .order_by(func.sum(TimeEntry.duration_seconds).desc()) + .all() + ) + labels = [username for username, _ in results] data = [round(seconds / 3600, 2) for _, seconds in results] - - return jsonify({ - 'labels': labels, - 'datasets': [{ - 'label': 'Hours', - 'data': data, - 'backgroundColor': 'rgba(59, 130, 246, 0.8)', - 'borderColor': '#3b82f6', - 'borderWidth': 2 - }] - }) - -@analytics_bp.route('/api/analytics/hours-by-hour') + + return jsonify( + { + "labels": labels, + "datasets": [ + { + "label": "Hours", + "data": data, + "backgroundColor": "rgba(59, 130, 246, 0.8)", + "borderColor": "#3b82f6", + "borderWidth": 2, + } + ], + } + ) + + +@analytics_bp.route("/api/analytics/hours-by-hour") @login_required def hours_by_hour(): """Get hours worked by hour of day (24-hour format)""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + query = db.session.query( - extract('hour', TimeEntry.start_time).label('hour'), - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date - ) - + extract("hour", TimeEntry.start_time).label("hour"), func.sum(TimeEntry.duration_seconds).label("total_seconds") + ).filter(TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date) + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - - results = query.group_by(extract('hour', TimeEntry.start_time)).order_by(extract('hour', TimeEntry.start_time)).all() - + + results = ( + query.group_by(extract("hour", TimeEntry.start_time)).order_by(extract("hour", TimeEntry.start_time)).all() + ) + # Create 24-hour array hours_data = [0] * 24 for hour, total_seconds in results: if total_seconds is None: total_seconds = 0 hours_data[int(hour)] = round(total_seconds / 3600, 2) - + labels = [f"{hour:02d}:00" for hour in range(24)] - - return jsonify({ - 'labels': labels, - 'datasets': [{ - 'label': 'Hours Worked', - 'data': hours_data, - 'backgroundColor': 'rgba(16, 185, 129, 0.8)', - 'borderColor': '#10b981', - 'borderWidth': 2, - 'tension': 0.4 - }] - }) - -@analytics_bp.route('/api/analytics/billable-vs-nonbillable') + + return jsonify( + { + "labels": labels, + "datasets": [ + { + "label": "Hours Worked", + "data": hours_data, + "backgroundColor": "rgba(16, 185, 129, 0.8)", + "borderColor": "#10b981", + "borderWidth": 2, + "tension": 0.4, + } + ], + } + ) + + +@analytics_bp.route("/api/analytics/billable-vs-nonbillable") @login_required def billable_vs_nonbillable(): """Get billable vs non-billable hours breakdown""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - - query = db.session.query( - TimeEntry.billable, - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date + + query = db.session.query(TimeEntry.billable, func.sum(TimeEntry.duration_seconds).label("total_seconds")).filter( + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date ) - + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - + results = query.group_by(TimeEntry.billable).all() - + billable_hours = 0 nonbillable_hours = 0 - + for billable, total_seconds in results: if total_seconds is None: total_seconds = 0 @@ -241,58 +263,59 @@ def billable_vs_nonbillable(): billable_hours = hours else: nonbillable_hours = hours - - return jsonify({ - 'labels': ['Billable', 'Non-Billable'], - 'datasets': [{ - 'label': 'Hours', - 'data': [billable_hours, nonbillable_hours], - 'backgroundColor': ['#10b981', '#6b7280'], - 'borderColor': ['#059669', '#4b5563'], - 'borderWidth': 2 - }] - }) - -@analytics_bp.route('/api/analytics/weekly-trends') + + return jsonify( + { + "labels": ["Billable", "Non-Billable"], + "datasets": [ + { + "label": "Hours", + "data": [billable_hours, nonbillable_hours], + "backgroundColor": ["#10b981", "#6b7280"], + "borderColor": ["#059669", "#4b5563"], + "borderWidth": 2, + } + ], + } + ) + + +@analytics_bp.route("/api/analytics/weekly-trends") @login_required def weekly_trends(): """Get weekly trends over the last 12 weeks""" try: - weeks = int(request.args.get('weeks', 12)) + weeks = int(request.args.get("weeks", 12)) except (ValueError, TypeError): - return jsonify({'error': 'Invalid weeks parameter'}), 400 - + return jsonify({"error": "Invalid weeks parameter"}), 400 + end_date = datetime.now().date() start_date = end_date - timedelta(weeks=weeks) - + # Get all time entries and group by week in Python (database-agnostic) - query = db.session.query( - TimeEntry.start_time, - TimeEntry.duration_seconds - ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date + query = db.session.query(TimeEntry.start_time, TimeEntry.duration_seconds).filter( + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date ) - + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - + results = query.all() - + # Group by week in Python from collections import defaultdict + week_data = defaultdict(float) - + for start_time, duration_seconds in results: # Get the start of the week (Monday) for this entry if isinstance(start_time, str): try: - entry_date = datetime.strptime(start_time, '%Y-%m-%d %H:%M:%S').date() + entry_date = datetime.strptime(start_time, "%Y-%m-%d %H:%M:%S").date() except ValueError: # Try alternative format if the first one fails try: - entry_date = datetime.strptime(start_time, '%Y-%m-%d').date() + entry_date = datetime.strptime(start_time, "%Y-%m-%d").date() except ValueError: # Skip invalid date strings continue @@ -303,25 +326,25 @@ def weekly_trends(): else: # Skip if we can't determine the date continue - + # Ensure entry_date is a date object before calculating weekday if not isinstance(entry_date, type(end_date)): continue - + # Calculate Monday of that week week_start = entry_date - timedelta(days=entry_date.weekday()) week_data[week_start] += duration_seconds or 0 - + # Sort by week and format output labels = [] data = [] - + for week_start_key in sorted(week_data.keys()): # Ensure week_start is a date object before calling strftime if isinstance(week_start_key, str): # If it's a string, try to parse it try: - week_start_date = datetime.strptime(week_start_key, '%Y-%m-%d').date() + week_start_date = datetime.strptime(week_start_key, "%Y-%m-%d").date() except (ValueError, AttributeError): continue elif isinstance(week_start_key, type(end_date)): @@ -329,148 +352,167 @@ def weekly_trends(): else: # Skip if it's not a date object or string continue - - labels.append(week_start_date.strftime('%b %d')) + + labels.append(week_start_date.strftime("%b %d")) data.append(round(week_data[week_start_key] / 3600, 2)) - - return jsonify({ - 'labels': labels, - 'datasets': [{ - 'label': 'Weekly Hours', - 'data': data, - 'borderColor': '#8b5cf6', - 'backgroundColor': 'rgba(139, 92, 246, 0.1)', - 'tension': 0.4, - 'fill': True, - 'pointBackgroundColor': '#8b5cf6', - 'pointBorderColor': '#ffffff', - 'pointBorderWidth': 2 - }] - }) - -@analytics_bp.route('/api/analytics/overtime') + + return jsonify( + { + "labels": labels, + "datasets": [ + { + "label": "Weekly Hours", + "data": data, + "borderColor": "#8b5cf6", + "backgroundColor": "rgba(139, 92, 246, 0.1)", + "tension": 0.4, + "fill": True, + "pointBackgroundColor": "#8b5cf6", + "pointBorderColor": "#ffffff", + "pointBorderWidth": 2, + } + ], + } + ) + + +@analytics_bp.route("/api/analytics/overtime") @login_required def overtime_analytics(): """Get overtime statistics for the current user or all users (if admin)""" try: - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) except (ValueError, TypeError): - return jsonify({'error': 'Invalid days parameter'}), 400 - + return jsonify({"error": "Invalid days parameter"}), 400 + from app.utils.overtime import calculate_period_overtime, get_daily_breakdown - + end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # If admin, show all users; otherwise show current user only if current_user.is_admin: users = User.query.filter_by(is_active=True).all() else: users = [current_user] - + # Calculate overtime for each user user_overtime_data = [] total_overtime = 0 total_regular = 0 - + for user in users: overtime_info = calculate_period_overtime(user, start_date, end_date) - if overtime_info['total_hours'] > 0: # Only include users with tracked time - user_overtime_data.append({ - 'username': user.display_name, - 'regular_hours': overtime_info['regular_hours'], - 'overtime_hours': overtime_info['overtime_hours'], - 'total_hours': overtime_info['total_hours'], - 'days_with_overtime': overtime_info['days_with_overtime'] - }) - total_overtime += overtime_info['overtime_hours'] - total_regular += overtime_info['regular_hours'] - + if overtime_info["total_hours"] > 0: # Only include users with tracked time + user_overtime_data.append( + { + "username": user.display_name, + "regular_hours": overtime_info["regular_hours"], + "overtime_hours": overtime_info["overtime_hours"], + "total_hours": overtime_info["total_hours"], + "days_with_overtime": overtime_info["days_with_overtime"], + } + ) + total_overtime += overtime_info["overtime_hours"] + total_regular += overtime_info["regular_hours"] + # Get daily breakdown for chart if not current_user.is_admin: daily_data = get_daily_breakdown(current_user, start_date, end_date) else: # For admin, show aggregated daily data daily_data = [] - - return jsonify({ - 'users': user_overtime_data, - 'summary': { - 'total_regular_hours': round(total_regular, 2), - 'total_overtime_hours': round(total_overtime, 2), - 'total_hours': round(total_regular + total_overtime, 2), - 'overtime_percentage': round( - (total_overtime / (total_regular + total_overtime) * 100) - if (total_regular + total_overtime) > 0 else 0, - 1 - ) - }, - 'daily_breakdown': [ - { - 'date': day['date_str'], - 'regular_hours': day['regular_hours'], - 'overtime_hours': day['overtime_hours'], - 'total_hours': day['total_hours'] - } - for day in daily_data - ] - }) + + return jsonify( + { + "users": user_overtime_data, + "summary": { + "total_regular_hours": round(total_regular, 2), + "total_overtime_hours": round(total_overtime, 2), + "total_hours": round(total_regular + total_overtime, 2), + "overtime_percentage": round( + ( + (total_overtime / (total_regular + total_overtime) * 100) + if (total_regular + total_overtime) > 0 + else 0 + ), + 1, + ), + }, + "daily_breakdown": [ + { + "date": day["date_str"], + "regular_hours": day["regular_hours"], + "overtime_hours": day["overtime_hours"], + "total_hours": day["total_hours"], + } + for day in daily_data + ], + } + ) -@analytics_bp.route('/api/analytics/project-efficiency') +@analytics_bp.route("/api/analytics/project-efficiency") @login_required def project_efficiency(): """Get project efficiency metrics (hours vs billable amount)""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - - query = db.session.query( - Project.name, - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - Project.hourly_rate - ).join(TimeEntry).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date, - Project.status == 'active', - Project.billable == True, - Project.hourly_rate.isnot(None) + + query = ( + db.session.query(Project.name, func.sum(TimeEntry.duration_seconds).label("total_seconds"), Project.hourly_rate) + .join(TimeEntry) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + Project.status == "active", + Project.billable == True, + Project.hourly_rate.isnot(None), + ) ) - + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - - results = query.group_by(Project.name, Project.hourly_rate).order_by(func.sum(TimeEntry.duration_seconds).desc()).limit(8).all() - + + results = ( + query.group_by(Project.name, Project.hourly_rate) + .order_by(func.sum(TimeEntry.duration_seconds).desc()) + .limit(8) + .all() + ) + labels = [project for project, _, _ in results] hours_data = [round(seconds / 3600, 2) for _, seconds, _ in results] revenue_data = [round((seconds / 3600) * float(rate), 2) for _, seconds, rate in results] - - return jsonify({ - 'labels': labels, - 'datasets': [ - { - 'label': 'Hours', - 'data': hours_data, - 'backgroundColor': 'rgba(59, 130, 246, 0.8)', - 'borderColor': '#3b82f6', - 'borderWidth': 2, - 'yAxisID': 'y' - }, - { - 'label': 'Revenue', - 'data': revenue_data, - 'backgroundColor': 'rgba(16, 185, 129, 0.8)', - 'borderColor': '#10b981', - 'borderWidth': 2, - 'yAxisID': 'y1' - } - ] - }) + + return jsonify( + { + "labels": labels, + "datasets": [ + { + "label": "Hours", + "data": hours_data, + "backgroundColor": "rgba(59, 130, 246, 0.8)", + "borderColor": "#3b82f6", + "borderWidth": 2, + "yAxisID": "y", + }, + { + "label": "Revenue", + "data": revenue_data, + "backgroundColor": "rgba(16, 185, 129, 0.8)", + "borderColor": "#10b981", + "borderWidth": 2, + "yAxisID": "y1", + }, + ], + } + ) -@analytics_bp.route('/api/analytics/today-by-task') +@analytics_bp.route("/api/analytics/today-by-task") @login_required def today_by_task(): """Get today's total hours grouped by task (includes project-level entries without task). @@ -480,321 +522,298 @@ def today_by_task(): - user_id: admin-only override to view a specific user's data """ # Parse target date - date_str = request.args.get('date') + date_str = request.args.get("date") if date_str: try: - target_date = datetime.strptime(date_str, '%Y-%m-%d').date() + target_date = datetime.strptime(date_str, "%Y-%m-%d").date() except ValueError: - return jsonify({'error': 'Invalid date format, expected YYYY-MM-DD'}), 400 + return jsonify({"error": "Invalid date format, expected YYYY-MM-DD"}), 400 else: target_date = datetime.now().date() # Base query - query = db.session.query( - TimeEntry.task_id, - Task.name.label('task_name'), - TimeEntry.project_id, - Project.name.label('project_name'), - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).join( - Project, Project.id == TimeEntry.project_id - ).outerjoin( - Task, Task.id == TimeEntry.task_id - ).filter( - TimeEntry.end_time.isnot(None), - func.date(TimeEntry.start_time) == target_date + query = ( + db.session.query( + TimeEntry.task_id, + Task.name.label("task_name"), + TimeEntry.project_id, + Project.name.label("project_name"), + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + ) + .join(Project, Project.id == TimeEntry.project_id) + .outerjoin(Task, Task.id == TimeEntry.task_id) + .filter(TimeEntry.end_time.isnot(None), func.date(TimeEntry.start_time) == target_date) ) # Scope to current user unless admin (with optional override) if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) else: - user_id = request.args.get('user_id', type=int) + user_id = request.args.get("user_id", type=int) if user_id: query = query.filter(TimeEntry.user_id == user_id) - results = query.group_by( - TimeEntry.task_id, - Task.name, - TimeEntry.project_id, - Project.name - ).order_by(func.sum(TimeEntry.duration_seconds).desc()).all() + results = ( + query.group_by(TimeEntry.task_id, Task.name, TimeEntry.project_id, Project.name) + .order_by(func.sum(TimeEntry.duration_seconds).desc()) + .all() + ) rows = [] for task_id, task_name, project_id, project_name, total_seconds in results: total_seconds = int(total_seconds or 0) total_hours = round(total_seconds / 3600, 2) label = f"{project_name} • {task_name}" if task_name else f"{project_name} • No task" - rows.append({ - 'task_id': task_id, - 'task_name': task_name, - 'project_id': project_id, - 'project_name': project_name, - 'total_seconds': total_seconds, - 'total_hours': total_hours, - 'label': label - }) - - return jsonify({ - 'date': target_date.strftime('%Y-%m-%d'), - 'rows': rows - }) - - -@analytics_bp.route('/api/analytics/summary-with-comparison') + rows.append( + { + "task_id": task_id, + "task_name": task_name, + "project_id": project_id, + "project_name": project_name, + "total_seconds": total_seconds, + "total_hours": total_hours, + "label": label, + } + ) + + return jsonify({"date": target_date.strftime("%Y-%m-%d"), "rows": rows}) + + +@analytics_bp.route("/api/analytics/summary-with-comparison") @login_required def summary_with_comparison(): """Get summary metrics with comparison to previous period""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Previous period dates prev_end_date = start_date - timedelta(days=1) prev_start_date = prev_end_date - timedelta(days=days) - + # Current period query current_query = db.session.query( - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - func.count(TimeEntry.id).label('total_entries'), - func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds') - ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date - ) - + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + func.count(TimeEntry.id).label("total_entries"), + func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label("billable_seconds"), + ).filter(TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date) + # Previous period query prev_query = db.session.query( - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - func.count(TimeEntry.id).label('total_entries'), - func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds') + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + func.count(TimeEntry.id).label("total_entries"), + func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label("billable_seconds"), ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= prev_start_date, - TimeEntry.start_time <= prev_end_date + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= prev_start_date, TimeEntry.start_time <= prev_end_date ) - + if not current_user.is_admin: current_query = current_query.filter(TimeEntry.user_id == current_user.id) prev_query = prev_query.filter(TimeEntry.user_id == current_user.id) - + current_result = current_query.first() prev_result = prev_query.first() - + current_hours = round((current_result.total_seconds or 0) / 3600, 1) prev_hours = round((prev_result.total_seconds or 0) / 3600, 1) hours_change = ((current_hours - prev_hours) / prev_hours * 100) if prev_hours > 0 else 0 - + current_billable = round((current_result.billable_seconds or 0) / 3600, 1) prev_billable = round((prev_result.billable_seconds or 0) / 3600, 1) billable_change = ((current_billable - prev_billable) / prev_billable * 100) if prev_billable > 0 else 0 - + current_entries = current_result.total_entries or 0 prev_entries = prev_result.total_entries or 0 entries_change = ((current_entries - prev_entries) / prev_entries * 100) if prev_entries > 0 else 0 - + # Get active projects count - active_projects = Project.query.filter_by(status='active').count() - + active_projects = Project.query.filter_by(status="active").count() + # Calculate average daily hours avg_daily_hours = round(current_hours / days, 1) if days > 0 else 0 - + # Calculate billable percentage billable_percentage = round((current_billable / current_hours * 100), 1) if current_hours > 0 else 0 - + # Get payment data for the period payment_query = db.session.query( - func.sum(Payment.amount).label('total_payments'), - func.count(Payment.id).label('payment_count') - ).filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date, - Payment.status == 'completed' - ) - + func.sum(Payment.amount).label("total_payments"), func.count(Payment.id).label("payment_count") + ).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date, Payment.status == "completed") + if not current_user.is_admin: - payment_query = payment_query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id + payment_query = ( + payment_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id) ) - + payment_result = payment_query.first() total_payments = float(payment_result.total_payments or 0) payment_count = payment_result.payment_count or 0 - - return jsonify({ - 'total_hours': current_hours, - 'total_hours_change': round(hours_change, 1), - 'billable_hours': current_billable, - 'billable_hours_change': round(billable_change, 1), - 'total_entries': current_entries, - 'entries_change': round(entries_change, 1), - 'active_projects': active_projects, - 'avg_daily_hours': avg_daily_hours, - 'billable_percentage': billable_percentage, - 'total_payments': round(total_payments, 2), - 'payment_count': payment_count - }) - - -@analytics_bp.route('/api/analytics/task-completion') + + return jsonify( + { + "total_hours": current_hours, + "total_hours_change": round(hours_change, 1), + "billable_hours": current_billable, + "billable_hours_change": round(billable_change, 1), + "total_entries": current_entries, + "entries_change": round(entries_change, 1), + "active_projects": active_projects, + "avg_daily_hours": avg_daily_hours, + "billable_percentage": billable_percentage, + "total_payments": round(total_payments, 2), + "payment_count": payment_count, + } + ) + + +@analytics_bp.route("/api/analytics/task-completion") @login_required def task_completion(): """Get task completion analytics""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Get tasks completed in period - completed_query = db.session.query( - func.count(Task.id).label('count') - ).filter( - Task.status == 'done', - Task.completed_at >= start_date, - Task.completed_at <= end_date + completed_query = db.session.query(func.count(Task.id).label("count")).filter( + Task.status == "done", Task.completed_at >= start_date, Task.completed_at <= end_date ) - + if not current_user.is_admin: completed_query = completed_query.filter(Task.assigned_to == current_user.id) - + completed_count = completed_query.scalar() or 0 - + # Get tasks by status - status_query = db.session.query( - Task.status, - func.count(Task.id).label('count') - ).filter( + status_query = db.session.query(Task.status, func.count(Task.id).label("count")).filter( Task.created_at >= start_date ) - + if not current_user.is_admin: status_query = status_query.filter(Task.assigned_to == current_user.id) - + status_results = status_query.group_by(Task.status).all() - - status_data = { - 'todo': 0, - 'in_progress': 0, - 'review': 0, - 'done': 0, - 'cancelled': 0 - } - + + status_data = {"todo": 0, "in_progress": 0, "review": 0, "done": 0, "cancelled": 0} + for status, count in status_results: if status in status_data: status_data[status] = count - + # Get task completion rate by project - project_query = db.session.query( - Project.name, - func.count(Task.id).label('total_tasks'), - func.sum(case((Task.status == 'done', 1), else_=0)).label('completed_tasks') - ).join(Task).filter( - Task.created_at >= start_date, - Project.status == 'active' + project_query = ( + db.session.query( + Project.name, + func.count(Task.id).label("total_tasks"), + func.sum(case((Task.status == "done", 1), else_=0)).label("completed_tasks"), + ) + .join(Task) + .filter(Task.created_at >= start_date, Project.status == "active") ) - + if not current_user.is_admin: project_query = project_query.filter(Task.assigned_to == current_user.id) - - project_results = project_query.group_by(Project.name).order_by( - func.count(Task.id).desc() - ).limit(10).all() - + + project_results = project_query.group_by(Project.name).order_by(func.count(Task.id).desc()).limit(10).all() + project_labels = [] project_completion_rates = [] - + for project_name, total, completed in project_results: project_labels.append(project_name) rate = (completed / total * 100) if total > 0 else 0 project_completion_rates.append(round(rate, 1)) - - return jsonify({ - 'completed_count': completed_count, - 'status_breakdown': status_data, - 'project_labels': project_labels, - 'project_completion_rates': project_completion_rates - }) + + return jsonify( + { + "completed_count": completed_count, + "status_breakdown": status_data, + "project_labels": project_labels, + "project_completion_rates": project_completion_rates, + } + ) -@analytics_bp.route('/api/analytics/revenue-metrics') +@analytics_bp.route("/api/analytics/revenue-metrics") @login_required def revenue_metrics(): """Get revenue and financial metrics""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + settings = Settings.get_settings() currency = settings.currency - + # Get billable hours with rates - query = db.session.query( - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - Project.hourly_rate - ).join(Project).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date, - TimeEntry.billable == True, - Project.billable == True, - Project.hourly_rate.isnot(None) + query = ( + db.session.query(func.sum(TimeEntry.duration_seconds).label("total_seconds"), Project.hourly_rate) + .join(Project) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + TimeEntry.billable == True, + Project.billable == True, + Project.hourly_rate.isnot(None), + ) ) - + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - + results = query.group_by(Project.hourly_rate).all() - + total_revenue = 0 for seconds, rate in results: if seconds and rate: hours = seconds / 3600 total_revenue += hours * float(rate) - + # Get billable hours - billable_query = db.session.query( - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).filter( + billable_query = db.session.query(func.sum(TimeEntry.duration_seconds).label("total_seconds")).filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date, - TimeEntry.billable == True + TimeEntry.billable == True, ) - + if not current_user.is_admin: billable_query = billable_query.filter(TimeEntry.user_id == current_user.id) - + billable_seconds = billable_query.scalar() or 0 billable_hours = round(billable_seconds / 3600, 1) - + # Calculate average hourly rate avg_hourly_rate = (total_revenue / billable_hours) if billable_hours > 0 else 0 - + # Get revenue by project - project_query = db.session.query( - Project.name, - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - Project.hourly_rate - ).join(TimeEntry).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date, - TimeEntry.billable == True, - Project.billable == True, - Project.hourly_rate.isnot(None) + project_query = ( + db.session.query(Project.name, func.sum(TimeEntry.duration_seconds).label("total_seconds"), Project.hourly_rate) + .join(TimeEntry) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + TimeEntry.billable == True, + Project.billable == True, + Project.hourly_rate.isnot(None), + ) ) - + if not current_user.is_admin: project_query = project_query.filter(TimeEntry.user_id == current_user.id) - - project_results = project_query.group_by( - Project.name, Project.hourly_rate - ).order_by(func.sum(TimeEntry.duration_seconds).desc()).limit(8).all() - + + project_results = ( + project_query.group_by(Project.name, Project.hourly_rate) + .order_by(func.sum(TimeEntry.duration_seconds).desc()) + .limit(8) + .all() + ) + project_labels = [] project_revenue = [] - + for project_name, seconds, rate in project_results: project_labels.append(project_name) if seconds and rate: @@ -802,446 +821,424 @@ def revenue_metrics(): project_revenue.append(round(revenue, 2)) else: project_revenue.append(0) - - return jsonify({ - 'total_revenue': round(total_revenue, 2), - 'billable_hours': billable_hours, - 'avg_hourly_rate': round(avg_hourly_rate, 2), - 'currency': currency, - 'project_labels': project_labels, - 'project_revenue': project_revenue - }) - - -@analytics_bp.route('/api/analytics/insights') + + return jsonify( + { + "total_revenue": round(total_revenue, 2), + "billable_hours": billable_hours, + "avg_hourly_rate": round(avg_hourly_rate, 2), + "currency": currency, + "project_labels": project_labels, + "project_revenue": project_revenue, + } + ) + + +@analytics_bp.route("/api/analytics/insights") @login_required def insights(): """Generate insights and recommendations based on analytics data""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + insights_list = [] - + # Analyze time entries query = db.session.query( - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - func.avg(TimeEntry.duration_seconds).label('avg_seconds'), - func.count(TimeEntry.id).label('total_entries'), - func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label('billable_seconds') - ).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date - ) - + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + func.avg(TimeEntry.duration_seconds).label("avg_seconds"), + func.count(TimeEntry.id).label("total_entries"), + func.sum(case((TimeEntry.billable == True, TimeEntry.duration_seconds), else_=0)).label("billable_seconds"), + ).filter(TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date) + if not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - + result = query.first() - + total_hours = (result.total_seconds or 0) / 3600 billable_hours = (result.billable_seconds or 0) / 3600 avg_entry_hours = (result.avg_seconds or 0) / 3600 - + # Insight 1: Billable ratio if total_hours > 0: billable_ratio = (billable_hours / total_hours) * 100 if billable_ratio < 60: - insights_list.append({ - 'type': 'warning', - 'icon': 'fas fa-exclamation-triangle', - 'title': 'Low Billable Ratio', - 'message': f'Only {billable_ratio:.1f}% of your time is billable. Consider focusing on billable projects.' - }) + insights_list.append( + { + "type": "warning", + "icon": "fas fa-exclamation-triangle", + "title": "Low Billable Ratio", + "message": f"Only {billable_ratio:.1f}% of your time is billable. Consider focusing on billable projects.", + } + ) elif billable_ratio > 85: - insights_list.append({ - 'type': 'success', - 'icon': 'fas fa-trophy', - 'title': 'Excellent Billable Ratio', - 'message': f'You have {billable_ratio:.1f}% billable time. Great work!' - }) - + insights_list.append( + { + "type": "success", + "icon": "fas fa-trophy", + "title": "Excellent Billable Ratio", + "message": f"You have {billable_ratio:.1f}% billable time. Great work!", + } + ) + # Insight 2: Average daily hours avg_daily = total_hours / days if days > 0 else 0 if avg_daily < 4: - insights_list.append({ - 'type': 'info', - 'icon': 'fas fa-chart-line', - 'title': 'Low Activity', - 'message': f'Average of {avg_daily:.1f}h per day. Consider tracking more consistently.' - }) + insights_list.append( + { + "type": "info", + "icon": "fas fa-chart-line", + "title": "Low Activity", + "message": f"Average of {avg_daily:.1f}h per day. Consider tracking more consistently.", + } + ) elif avg_daily > 10: - insights_list.append({ - 'type': 'warning', - 'icon': 'fas fa-battery-empty', - 'title': 'High Workload', - 'message': f'Averaging {avg_daily:.1f}h per day. Remember to take breaks!' - }) - + insights_list.append( + { + "type": "warning", + "icon": "fas fa-battery-empty", + "title": "High Workload", + "message": f"Averaging {avg_daily:.1f}h per day. Remember to take breaks!", + } + ) + # Insight 3: Project diversity project_count = db.session.query(func.count(func.distinct(TimeEntry.project_id))).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date ) - + if not current_user.is_admin: project_count = project_count.filter(TimeEntry.user_id == current_user.id) - + num_projects = project_count.scalar() or 0 - + if num_projects > 8: - insights_list.append({ - 'type': 'info', - 'icon': 'fas fa-tasks', - 'title': 'Multiple Projects', - 'message': f'Working on {num_projects} projects. Consider consolidating focus.' - }) - + insights_list.append( + { + "type": "info", + "icon": "fas fa-tasks", + "title": "Multiple Projects", + "message": f"Working on {num_projects} projects. Consider consolidating focus.", + } + ) + # Insight 4: Weekend work (if any) - weekend_query = db.session.query( - func.sum(TimeEntry.duration_seconds).label('weekend_seconds') - ).filter( + weekend_query = db.session.query(func.sum(TimeEntry.duration_seconds).label("weekend_seconds")).filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_date, TimeEntry.start_time <= end_date, - extract('dow', TimeEntry.start_time).in_([0, 6]) # Sunday=0, Saturday=6 + extract("dow", TimeEntry.start_time).in_([0, 6]), # Sunday=0, Saturday=6 ) - + if not current_user.is_admin: weekend_query = weekend_query.filter(TimeEntry.user_id == current_user.id) - + weekend_seconds = weekend_query.scalar() or 0 weekend_hours = weekend_seconds / 3600 - + if weekend_hours > 5: weekend_percent = (weekend_hours / total_hours * 100) if total_hours > 0 else 0 - insights_list.append({ - 'type': 'warning', - 'icon': 'fas fa-calendar-times', - 'title': 'Weekend Work', - 'message': f'{weekend_percent:.0f}% of work done on weekends ({weekend_hours:.1f}h). Consider work-life balance.' - }) - - return jsonify({ - 'insights': insights_list - }) - - -@analytics_bp.route('/api/analytics/payments-over-time') + insights_list.append( + { + "type": "warning", + "icon": "fas fa-calendar-times", + "title": "Weekend Work", + "message": f"{weekend_percent:.0f}% of work done on weekends ({weekend_hours:.1f}h). Consider work-life balance.", + } + ) + + return jsonify({"insights": insights_list}) + + +@analytics_bp.route("/api/analytics/payments-over-time") @login_required def payments_over_time(): """Get payments over time""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Build query query = db.session.query( - func.date(Payment.payment_date).label('date'), - func.sum(Payment.amount).label('total_amount'), - func.count(Payment.id).label('payment_count') - ).filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date - ) - + func.date(Payment.payment_date).label("date"), + func.sum(Payment.amount).label("total_amount"), + func.count(Payment.id).label("payment_count"), + ).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date) + if not current_user.is_admin: - query = query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id - ).distinct() - + query = ( + query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id).distinct() + ) + results = query.group_by(func.date(Payment.payment_date)).all() - + # Create date range and fill missing dates with 0 date_data = {} current_date = start_date while current_date <= end_date: - date_data[current_date.strftime('%Y-%m-%d')] = 0 + date_data[current_date.strftime("%Y-%m-%d")] = 0 current_date += timedelta(days=1) - + # Fill in actual data for date_obj, total_amount, _ in results: if date_obj: if isinstance(date_obj, str): formatted_date = date_obj - elif hasattr(date_obj, 'strftime'): - formatted_date = date_obj.strftime('%Y-%m-%d') + elif hasattr(date_obj, "strftime"): + formatted_date = date_obj.strftime("%Y-%m-%d") else: # Skip if we can't format the date continue date_data[formatted_date] = float(total_amount or 0) - - return jsonify({ - 'labels': list(date_data.keys()), - 'datasets': [{ - 'label': 'Payments Received', - 'data': list(date_data.values()), - 'borderColor': '#10b981', - 'backgroundColor': 'rgba(16, 185, 129, 0.1)', - 'tension': 0.4, - 'fill': True - }] - }) - - -@analytics_bp.route('/api/analytics/payments-by-status') + + return jsonify( + { + "labels": list(date_data.keys()), + "datasets": [ + { + "label": "Payments Received", + "data": list(date_data.values()), + "borderColor": "#10b981", + "backgroundColor": "rgba(16, 185, 129, 0.1)", + "tension": 0.4, + "fill": True, + } + ], + } + ) + + +@analytics_bp.route("/api/analytics/payments-by-status") @login_required def payments_by_status(): """Get payment breakdown by status""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + query = db.session.query( - Payment.status, - func.count(Payment.id).label('count'), - func.sum(Payment.amount).label('total_amount') - ).filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date - ) - + Payment.status, func.count(Payment.id).label("count"), func.sum(Payment.amount).label("total_amount") + ).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date) + if not current_user.is_admin: - query = query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id - ).distinct() - + query = ( + query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id).distinct() + ) + results = query.group_by(Payment.status).all() - + labels = [] counts = [] amounts = [] - colors = { - 'completed': '#10b981', - 'pending': '#f59e0b', - 'failed': '#ef4444', - 'refunded': '#6b7280' - } + colors = {"completed": "#10b981", "pending": "#f59e0b", "failed": "#ef4444", "refunded": "#6b7280"} background_colors = [] - + for status, count, amount in results: - labels.append(status.title() if status else 'Unknown') + labels.append(status.title() if status else "Unknown") counts.append(count) amounts.append(float(amount or 0)) - background_colors.append(colors.get(status, '#3b82f6')) - - return jsonify({ - 'labels': labels, - 'count_dataset': { - 'label': 'Payment Count', - 'data': counts, - 'backgroundColor': background_colors - }, - 'amount_dataset': { - 'label': 'Total Amount', - 'data': amounts, - 'backgroundColor': background_colors + background_colors.append(colors.get(status, "#3b82f6")) + + return jsonify( + { + "labels": labels, + "count_dataset": {"label": "Payment Count", "data": counts, "backgroundColor": background_colors}, + "amount_dataset": {"label": "Total Amount", "data": amounts, "backgroundColor": background_colors}, } - }) + ) -@analytics_bp.route('/api/analytics/payments-by-method') +@analytics_bp.route("/api/analytics/payments-by-method") @login_required def payments_by_method(): """Get payment breakdown by payment method""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + query = db.session.query( - Payment.method, - func.count(Payment.id).label('count'), - func.sum(Payment.amount).label('total_amount') - ).filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date, - Payment.method.isnot(None) - ) - + Payment.method, func.count(Payment.id).label("count"), func.sum(Payment.amount).label("total_amount") + ).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date, Payment.method.isnot(None)) + if not current_user.is_admin: - query = query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id - ).distinct() - + query = ( + query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id).distinct() + ) + results = query.group_by(Payment.method).order_by(func.sum(Payment.amount).desc()).all() - + labels = [] amounts = [] colors = [ - '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', - '#06b6d4', '#84cc16', '#f97316', '#ec4899', '#6366f1' + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#06b6d4", + "#84cc16", + "#f97316", + "#ec4899", + "#6366f1", ] - + for idx, (method, _, amount) in enumerate(results): - labels.append(method.replace('_', ' ').title() if method else 'Other') + labels.append(method.replace("_", " ").title() if method else "Other") amounts.append(float(amount or 0)) - - return jsonify({ - 'labels': labels, - 'datasets': [{ - 'label': 'Amount', - 'data': amounts, - 'backgroundColor': colors[:len(labels)], - 'borderWidth': 2 - }] - }) - - -@analytics_bp.route('/api/analytics/payment-summary') + + return jsonify( + { + "labels": labels, + "datasets": [ + {"label": "Amount", "data": amounts, "backgroundColor": colors[: len(labels)], "borderWidth": 2} + ], + } + ) + + +@analytics_bp.route("/api/analytics/payment-summary") @login_required def payment_summary(): """Get payment summary statistics""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Previous period prev_end_date = start_date - timedelta(days=1) prev_start_date = prev_end_date - timedelta(days=days) - + # Current period query current_query = db.session.query( - func.sum(Payment.amount).label('total_amount'), - func.count(Payment.id).label('payment_count'), - func.sum(Payment.gateway_fee).label('total_fees'), - func.sum(Payment.net_amount).label('total_net') - ).filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date - ) - + func.sum(Payment.amount).label("total_amount"), + func.count(Payment.id).label("payment_count"), + func.sum(Payment.gateway_fee).label("total_fees"), + func.sum(Payment.net_amount).label("total_net"), + ).filter(Payment.payment_date >= start_date, Payment.payment_date <= end_date) + # Previous period query prev_query = db.session.query( - func.sum(Payment.amount).label('total_amount'), - func.count(Payment.id).label('payment_count') - ).filter( - Payment.payment_date >= prev_start_date, - Payment.payment_date <= prev_end_date - ) - + func.sum(Payment.amount).label("total_amount"), func.count(Payment.id).label("payment_count") + ).filter(Payment.payment_date >= prev_start_date, Payment.payment_date <= prev_end_date) + if not current_user.is_admin: - current_query = current_query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id + current_query = ( + current_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id) ) - prev_query = prev_query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id - ) - + prev_query = prev_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id) + current_result = current_query.first() prev_result = prev_query.first() - + current_amount = float(current_result.total_amount or 0) prev_amount = float(prev_result.total_amount or 0) amount_change = ((current_amount - prev_amount) / prev_amount * 100) if prev_amount > 0 else 0 - + current_count = current_result.payment_count or 0 prev_count = prev_result.payment_count or 0 count_change = ((current_count - prev_count) / prev_count * 100) if prev_count > 0 else 0 - + total_fees = float(current_result.total_fees or 0) total_net = float(current_result.total_net or 0) - + # Get completed vs pending - status_query = db.session.query( - Payment.status, - func.sum(Payment.amount).label('amount') - ).filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date + status_query = db.session.query(Payment.status, func.sum(Payment.amount).label("amount")).filter( + Payment.payment_date >= start_date, Payment.payment_date <= end_date ) - + if not current_user.is_admin: - status_query = status_query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id + status_query = ( + status_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id) ) - + status_results = status_query.group_by(Payment.status).all() - + completed_amount = 0 pending_amount = 0 - + for status, amount in status_results: - if status == 'completed': + if status == "completed": completed_amount = float(amount or 0) - elif status == 'pending': + elif status == "pending": pending_amount = float(amount or 0) - - return jsonify({ - 'total_amount': round(current_amount, 2), - 'amount_change': round(amount_change, 1), - 'payment_count': current_count, - 'count_change': round(count_change, 1), - 'total_fees': round(total_fees, 2), - 'total_net': round(total_net, 2), - 'completed_amount': round(completed_amount, 2), - 'pending_amount': round(pending_amount, 2), - 'avg_payment': round(current_amount / current_count, 2) if current_count > 0 else 0 - }) - - -@analytics_bp.route('/api/analytics/revenue-vs-payments') + + return jsonify( + { + "total_amount": round(current_amount, 2), + "amount_change": round(amount_change, 1), + "payment_count": current_count, + "count_change": round(count_change, 1), + "total_fees": round(total_fees, 2), + "total_net": round(total_net, 2), + "completed_amount": round(completed_amount, 2), + "pending_amount": round(pending_amount, 2), + "avg_payment": round(current_amount / current_count, 2) if current_count > 0 else 0, + } + ) + + +@analytics_bp.route("/api/analytics/revenue-vs-payments") @login_required def revenue_vs_payments(): """Compare potential revenue (from time tracking) with actual payments""" - days = int(request.args.get('days', 30)) + days = int(request.args.get("days", 30)) end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + settings = Settings.get_settings() currency = settings.currency - + # Get billable revenue (potential) - revenue_query = db.session.query( - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - Project.hourly_rate - ).join(Project).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_date, - TimeEntry.start_time <= end_date, - TimeEntry.billable == True, - Project.billable == True, - Project.hourly_rate.isnot(None) + revenue_query = ( + db.session.query(func.sum(TimeEntry.duration_seconds).label("total_seconds"), Project.hourly_rate) + .join(Project) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_date, + TimeEntry.start_time <= end_date, + TimeEntry.billable == True, + Project.billable == True, + Project.hourly_rate.isnot(None), + ) ) - + if not current_user.is_admin: revenue_query = revenue_query.filter(TimeEntry.user_id == current_user.id) - + revenue_results = revenue_query.group_by(Project.hourly_rate).all() - + potential_revenue = 0 for seconds, rate in revenue_results: if seconds and rate: hours = seconds / 3600 potential_revenue += hours * float(rate) - + # Get actual payments - payment_query = db.session.query( - func.sum(Payment.amount).label('total_amount') - ).filter( - Payment.payment_date >= start_date, - Payment.payment_date <= end_date, - Payment.status == 'completed' + payment_query = db.session.query(func.sum(Payment.amount).label("total_amount")).filter( + Payment.payment_date >= start_date, Payment.payment_date <= end_date, Payment.status == "completed" ) - + if not current_user.is_admin: - payment_query = payment_query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == current_user.id + payment_query = ( + payment_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == current_user.id) ) - + actual_payments = payment_query.scalar() or 0 actual_payments = float(actual_payments) - + collection_rate = (actual_payments / potential_revenue * 100) if potential_revenue > 0 else 0 outstanding = potential_revenue - actual_payments - - return jsonify({ - 'potential_revenue': round(potential_revenue, 2), - 'actual_payments': round(actual_payments, 2), - 'outstanding': round(outstanding, 2), - 'collection_rate': round(collection_rate, 1), - 'currency': currency, - 'labels': ['Collected', 'Outstanding'], - 'data': [round(actual_payments, 2), round(outstanding, 2) if outstanding > 0 else 0] - }) + + return jsonify( + { + "potential_revenue": round(potential_revenue, 2), + "actual_payments": round(actual_payments, 2), + "outstanding": round(outstanding, 2), + "collection_rate": round(collection_rate, 1), + "currency": currency, + "labels": ["Collected", "Outstanding"], + "data": [round(actual_payments, 2), round(outstanding, 2) if outstanding > 0 else 0], + } + ) diff --git a/app/routes/api.py b/app/routes/api.py index 6ebff4ea..38e7f3aa 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -1,7 +1,18 @@ from flask import Blueprint, jsonify, request, current_app, send_from_directory, make_response from flask_login import login_required, current_user from app import db, socketio -from app.models import User, Project, TimeEntry, Settings, Task, FocusSession, RecurringBlock, RateOverride, SavedFilter, Client +from app.models import ( + User, + Project, + TimeEntry, + Settings, + Task, + FocusSession, + RecurringBlock, + RateOverride, + SavedFilter, + Client, +) from datetime import datetime, timedelta, time from app.utils.db import safe_commit from app.utils.timezone import parse_local_datetime, utc_to_local, convert_app_datetime_to_user @@ -12,165 +23,180 @@ import uuid from werkzeug.utils import secure_filename -api_bp = Blueprint('api', __name__) +api_bp = Blueprint("api", __name__) -@api_bp.route('/api/health') + +@api_bp.route("/api/health") def health_check(): """Health check endpoint for monitoring and error handling""" - return jsonify({ - 'status': 'ok', - 'timestamp': datetime.utcnow().isoformat() - }) + return jsonify({"status": "ok", "timestamp": datetime.utcnow().isoformat()}) + -@api_bp.route('/api/timer/status') +@api_bp.route("/api/timer/status") @login_required def timer_status(): """Get current timer status""" active_timer = current_user.active_timer - + if not active_timer: - return jsonify({ - 'active': False, - 'timer': None - }) - - return jsonify({ - 'active': True, - 'timer': { - 'id': active_timer.id, - 'project_name': active_timer.project.name, - 'project_id': active_timer.project_id, - 'task_id': active_timer.task_id, - 'start_time': active_timer.start_time.isoformat(), - 'current_duration': active_timer.current_duration_seconds, - 'duration_formatted': active_timer.duration_formatted + return jsonify({"active": False, "timer": None}) + + return jsonify( + { + "active": True, + "timer": { + "id": active_timer.id, + "project_name": active_timer.project.name, + "project_id": active_timer.project_id, + "task_id": active_timer.task_id, + "start_time": active_timer.start_time.isoformat(), + "current_duration": active_timer.current_duration_seconds, + "duration_formatted": active_timer.duration_formatted, + }, } - }) + ) -@api_bp.route('/api/search') + +@api_bp.route("/api/search") @login_required def search(): """Global search endpoint for projects, tasks, clients, and time entries""" - query = request.args.get('q', '').strip() - limit = request.args.get('limit', 10, type=int) - + query = request.args.get("q", "").strip() + limit = request.args.get("limit", 10, type=int) + if not query or len(query) < 2: - return jsonify({'results': []}) - + return jsonify({"results": []}) + results = [] - search_pattern = f'%{query}%' - + search_pattern = f"%{query}%" + # Search projects try: - projects = Project.query.filter( - Project.status == 'active', - or_( - Project.name.ilike(search_pattern), - Project.description.ilike(search_pattern) + projects = ( + Project.query.filter( + Project.status == "active", + or_(Project.name.ilike(search_pattern), Project.description.ilike(search_pattern)), ) - ).limit(limit).all() - + .limit(limit) + .all() + ) + for project in projects: - results.append({ - 'type': 'project', - 'category': 'project', - 'id': project.id, - 'title': project.name, - 'description': project.description or '', - 'url': f'/projects/{project.id}', - 'badge': 'Project' - }) + results.append( + { + "type": "project", + "category": "project", + "id": project.id, + "title": project.name, + "description": project.description or "", + "url": f"/projects/{project.id}", + "badge": "Project", + } + ) except Exception as e: current_app.logger.error(f"Error searching projects: {e}") - + # Search tasks try: - tasks = Task.query.join(Project).filter( - Project.status == 'active', - or_( - Task.name.ilike(search_pattern), - Task.description.ilike(search_pattern) + tasks = ( + Task.query.join(Project) + .filter( + Project.status == "active", or_(Task.name.ilike(search_pattern), Task.description.ilike(search_pattern)) ) - ).limit(limit).all() - + .limit(limit) + .all() + ) + for task in tasks: - results.append({ - 'type': 'task', - 'category': 'task', - 'id': task.id, - 'title': task.name, - 'description': f"{task.project.name if task.project else 'No Project'}", - 'url': f'/tasks/{task.id}', - 'badge': task.status.replace('_', ' ').title() if task.status else 'Task' - }) + results.append( + { + "type": "task", + "category": "task", + "id": task.id, + "title": task.name, + "description": f"{task.project.name if task.project else 'No Project'}", + "url": f"/tasks/{task.id}", + "badge": task.status.replace("_", " ").title() if task.status else "Task", + } + ) except Exception as e: current_app.logger.error(f"Error searching tasks: {e}") - + # Search clients try: - clients = Client.query.filter( - or_( - Client.name.ilike(search_pattern), - Client.email.ilike(search_pattern), - Client.company.ilike(search_pattern) + clients = ( + Client.query.filter( + or_( + Client.name.ilike(search_pattern), + Client.email.ilike(search_pattern), + Client.company.ilike(search_pattern), + ) ) - ).limit(limit).all() - + .limit(limit) + .all() + ) + for client in clients: - results.append({ - 'type': 'client', - 'category': 'client', - 'id': client.id, - 'title': client.name, - 'description': client.company or client.email or '', - 'url': f'/clients/{client.id}', - 'badge': 'Client' - }) + results.append( + { + "type": "client", + "category": "client", + "id": client.id, + "title": client.name, + "description": client.company or client.email or "", + "url": f"/clients/{client.id}", + "badge": "Client", + } + ) except Exception as e: current_app.logger.error(f"Error searching clients: {e}") - + # Search time entries (notes and tags) try: - entries = TimeEntry.query.filter( - TimeEntry.user_id == current_user.id, - TimeEntry.end_time.isnot(None), - or_( - TimeEntry.notes.ilike(search_pattern), - TimeEntry.tags.ilike(search_pattern) + entries = ( + TimeEntry.query.filter( + TimeEntry.user_id == current_user.id, + TimeEntry.end_time.isnot(None), + or_(TimeEntry.notes.ilike(search_pattern), TimeEntry.tags.ilike(search_pattern)), ) - ).order_by(TimeEntry.start_time.desc()).limit(limit).all() - + .order_by(TimeEntry.start_time.desc()) + .limit(limit) + .all() + ) + for entry in entries: title_parts = [] if entry.project: title_parts.append(entry.project.name) if entry.task: title_parts.append(f"• {entry.task.name}") - title = ' '.join(title_parts) if title_parts else 'Time Entry' - - description = entry.notes[:100] if entry.notes else '' + title = " ".join(title_parts) if title_parts else "Time Entry" + + description = entry.notes[:100] if entry.notes else "" if entry.tags: description += f" [{entry.tags}]" - - results.append({ - 'type': 'entry', - 'category': 'entry', - 'id': entry.id, - 'title': title, - 'description': description, - 'url': f'/timer/edit/{entry.id}', - 'badge': entry.duration_formatted - }) + + results.append( + { + "type": "entry", + "category": "entry", + "id": entry.id, + "title": title, + "description": description, + "url": f"/timer/edit/{entry.id}", + "badge": entry.duration_formatted, + } + ) except Exception as e: current_app.logger.error(f"Error searching time entries: {e}") - + # Limit total results results = results[:limit] - - return jsonify({'results': results}) + return jsonify({"results": results}) -@api_bp.route('/api/deadlines/upcoming') + +@api_bp.route("/api/deadlines/upcoming") @login_required def upcoming_deadlines(): """Return upcoming task deadlines for the current user.""" @@ -178,181 +204,164 @@ def upcoming_deadlines(): today = now_utc.date() horizon = (now_utc + timedelta(days=2)).date() - query = ( - Task.query - .join(Project) - .filter( - Project.status == 'active', - Task.due_date.isnot(None), - Task.status.in_(('todo', 'in_progress', 'review')), - Task.due_date >= today, - Task.due_date <= horizon - ) + query = Task.query.join(Project).filter( + Project.status == "active", + Task.due_date.isnot(None), + Task.status.in_(("todo", "in_progress", "review")), + Task.due_date >= today, + Task.due_date <= horizon, ) if not current_user.is_admin: - query = query.filter( - or_( - Task.assigned_to == current_user.id, - Task.created_by == current_user.id - ) - ) + query = query.filter(or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id)) - tasks = ( - query - .order_by(Task.due_date.asc(), Task.priority.desc(), Task.name.asc()) - .limit(20) - .all() - ) + tasks = query.order_by(Task.due_date.asc(), Task.priority.desc(), Task.name.asc()).limit(20).all() end_of_day = time(hour=23, minute=59, second=59) deadlines = [] for task in tasks: due_dt = datetime.combine(task.due_date, end_of_day) - deadlines.append({ - 'task_id': task.id, - 'task_name': task.name, - 'project_id': task.project_id, - 'project_name': task.project.name if task.project else None, - 'due_date': due_dt.isoformat(), - 'priority': task.priority, - 'status': task.status - }) + deadlines.append( + { + "task_id": task.id, + "task_name": task.name, + "project_id": task.project_id, + "project_name": task.project.name if task.project else None, + "due_date": due_dt.isoformat(), + "priority": task.priority, + "status": task.status, + } + ) return jsonify(deadlines) -@api_bp.route('/api/tasks') + +@api_bp.route("/api/tasks") @login_required def list_tasks_for_project(): """List tasks for a given project (optionally filter by status).""" - project_id = request.args.get('project_id', type=int) - status = request.args.get('status') + project_id = request.args.get("project_id", type=int) + status = request.args.get("status") if not project_id: - return jsonify({'error': 'project_id is required'}), 400 - + return jsonify({"error": "project_id is required"}), 400 + # Validate project exists and is active - project = Project.query.filter_by(id=project_id, status='active').first() + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - return jsonify({'error': 'Invalid project'}), 400 - + return jsonify({"error": "Invalid project"}), 400 + query = Task.query.filter_by(project_id=project_id) if status: query = query.filter_by(status=status) else: # Default to tasks not done/cancelled - query = query.filter(Task.status.in_(['todo', 'in_progress', 'review'])) - + query = query.filter(Task.status.in_(["todo", "in_progress", "review"])) + tasks = query.order_by(Task.priority.desc(), Task.name.asc()).all() - return jsonify({'tasks': [ - { - 'id': t.id, - 'name': t.name, - 'status': t.status, - 'priority': t.priority - } for t in tasks - ]}) - -@api_bp.route('/api/timer/start', methods=['POST']) + return jsonify({"tasks": [{"id": t.id, "name": t.name, "status": t.status, "priority": t.priority} for t in tasks]}) + + +@api_bp.route("/api/timer/start", methods=["POST"]) @login_required def api_start_timer(): """Start timer via API""" data = request.get_json() - project_id = data.get('project_id') - task_id = data.get('task_id') - + project_id = data.get("project_id") + task_id = data.get("task_id") + if not project_id: - return jsonify({'error': 'Project ID is required'}), 400 - + return jsonify({"error": "Project ID is required"}), 400 + # Check if project exists and is active - project = Project.query.filter_by(id=project_id, status='active').first() + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - return jsonify({'error': 'Invalid project'}), 400 - + return jsonify({"error": "Invalid project"}), 400 + # Validate task if provided task = None if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - return jsonify({'error': 'Invalid task for selected project'}), 400 + return jsonify({"error": "Invalid task for selected project"}), 400 # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - return jsonify({'error': 'User already has an active timer'}), 400 - + return jsonify({"error": "User already has an active timer"}), 400 + # Create new timer from app.models.time_entry import local_now + new_timer = TimeEntry( user_id=current_user.id, project_id=project_id, task_id=task.id if task else None, start_time=local_now(), - source='auto' + source="auto", ) - + db.session.add(new_timer) db.session.commit() - + # Emit WebSocket event - socketio.emit('timer_started', { - 'user_id': current_user.id, - 'timer_id': new_timer.id, - 'project_name': project.name, - 'task_id': task.id if task else None, - 'start_time': new_timer.start_time.isoformat() - }) - - return jsonify({ - 'success': True, - 'timer_id': new_timer.id, - 'project_name': project.name, - 'task_id': task.id if task else None - }) - -@api_bp.route('/api/timer/stop', methods=['POST']) + socketio.emit( + "timer_started", + { + "user_id": current_user.id, + "timer_id": new_timer.id, + "project_name": project.name, + "task_id": task.id if task else None, + "start_time": new_timer.start_time.isoformat(), + }, + ) + + return jsonify( + {"success": True, "timer_id": new_timer.id, "project_name": project.name, "task_id": task.id if task else None} + ) + + +@api_bp.route("/api/timer/stop", methods=["POST"]) @login_required def api_stop_timer(): """Stop timer via API""" active_timer = current_user.active_timer - + if not active_timer: - return jsonify({'error': 'No active timer to stop'}), 400 - + return jsonify({"error": "No active timer to stop"}), 400 + # Stop the timer active_timer.stop_timer() - + # Emit WebSocket event - socketio.emit('timer_stopped', { - 'user_id': current_user.id, - 'timer_id': active_timer.id, - 'duration': active_timer.duration_formatted - }) - - return jsonify({ - 'success': True, - 'duration': active_timer.duration_formatted, - 'duration_hours': active_timer.duration_hours - }) + socketio.emit( + "timer_stopped", + {"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted}, + ) + + return jsonify( + {"success": True, "duration": active_timer.duration_formatted, "duration_hours": active_timer.duration_hours} + ) + # --- Idle control: stop at specific time --- -@api_bp.route('/api/timer/stop_at', methods=['POST']) +@api_bp.route("/api/timer/stop_at", methods=["POST"]) @login_required def api_stop_timer_at(): """Stop the active timer at a specific timestamp (idle adjustment).""" active_timer = current_user.active_timer if not active_timer: - return jsonify({'error': 'No active timer to stop'}), 400 + return jsonify({"error": "No active timer to stop"}), 400 data = request.get_json() or {} - stop_time_str = data.get('stop_time') # ISO string + stop_time_str = data.get("stop_time") # ISO string if not stop_time_str: - return jsonify({'error': 'stop_time is required'}), 400 + return jsonify({"error": "stop_time is required"}), 400 try: # Accept ISO; handle trailing Z ts = stop_time_str.strip() - if ts.endswith('Z'): - ts = ts[:-1] + '+00:00' + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" parsed = datetime.fromisoformat(ts) # Convert to local naive for storage consistency if parsed.tzinfo is not None: @@ -361,10 +370,10 @@ def api_stop_timer_at(): else: stop_time_local = parsed except Exception: - return jsonify({'error': 'Invalid stop_time format'}), 400 + return jsonify({"error": "Invalid stop_time format"}), 400 if stop_time_local <= active_timer.start_time: - return jsonify({'error': 'stop_time must be after start time'}), 400 + return jsonify({"error": "stop_time must be after start time"}), 400 # Do not allow stopping in the future now_local = local_now() @@ -374,84 +383,83 @@ def api_stop_timer_at(): try: active_timer.stop_timer(end_time=stop_time_local) except Exception as e: - current_app.logger.warning('Failed to stop timer at specific time: %s', e) - return jsonify({'error': 'Failed to stop timer'}), 500 + current_app.logger.warning("Failed to stop timer at specific time: %s", e) + return jsonify({"error": "Failed to stop timer"}), 500 + + socketio.emit( + "timer_stopped", + {"user_id": current_user.id, "timer_id": active_timer.id, "duration": active_timer.duration_formatted}, + ) - socketio.emit('timer_stopped', { - 'user_id': current_user.id, - 'timer_id': active_timer.id, - 'duration': active_timer.duration_formatted - }) + return jsonify({"success": True, "duration": active_timer.duration_formatted}) - return jsonify({'success': True, 'duration': active_timer.duration_formatted}) # --- Resume last timer/project --- -@api_bp.route('/api/timer/resume', methods=['POST']) +@api_bp.route("/api/timer/resume", methods=["POST"]) @login_required def api_resume_timer(): """Resume timer for last used project/task or provided project/task.""" if current_user.active_timer: - return jsonify({'error': 'Timer already running'}), 400 + return jsonify({"error": "Timer already running"}), 400 data = request.get_json() or {} - project_id = data.get('project_id') - task_id = data.get('task_id') + project_id = data.get("project_id") + task_id = data.get("task_id") if not project_id: # Find most recent finished entry last = ( - TimeEntry.query - .filter(TimeEntry.user_id == current_user.id) + TimeEntry.query.filter(TimeEntry.user_id == current_user.id) .order_by(TimeEntry.end_time.desc().nullslast(), TimeEntry.start_time.desc()) .first() ) if not last: - return jsonify({'error': 'No previous entry to resume'}), 404 + return jsonify({"error": "No previous entry to resume"}), 404 project_id = last.project_id task_id = last.task_id # Validate project is active - project = Project.query.filter_by(id=project_id, status='active').first() + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - return jsonify({'error': 'Invalid or inactive project'}), 400 + return jsonify({"error": "Invalid or inactive project"}), 400 if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - return jsonify({'error': 'Invalid task for selected project'}), 400 + return jsonify({"error": "Invalid task for selected project"}), 400 # Create new timer new_timer = TimeEntry( - user_id=current_user.id, - project_id=project_id, - task_id=task_id, - start_time=local_now(), - source='auto' + user_id=current_user.id, project_id=project_id, task_id=task_id, start_time=local_now(), source="auto" ) db.session.add(new_timer) db.session.commit() - socketio.emit('timer_started', { - 'user_id': current_user.id, - 'timer_id': new_timer.id, - 'project_name': project.name, - 'task_id': task_id, - 'start_time': new_timer.start_time.isoformat() - }) + socketio.emit( + "timer_started", + { + "user_id": current_user.id, + "timer_id": new_timer.id, + "project_name": project.name, + "task_id": task_id, + "start_time": new_timer.start_time.isoformat(), + }, + ) + + return jsonify({"success": True, "timer_id": new_timer.id}) - return jsonify({'success': True, 'timer_id': new_timer.id}) -@api_bp.route('/api/entries') +@api_bp.route("/api/entries") @login_required def get_entries(): """Get time entries with pagination""" - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 20, type=int) - user_id = request.args.get('user_id', type=int) - project_id = request.args.get('project_id', type=int) - tag = (request.args.get('tag') or '').strip() - saved_filter_id = request.args.get('saved_filter_id', type=int) - + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 20, type=int) + user_id = request.args.get("user_id", type=int) + project_id = request.args.get("project_id", type=int) + tag = (request.args.get("tag") or "").strip() + saved_filter_id = request.args.get("saved_filter_id", type=int) + query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)) # Apply saved filter if provided @@ -459,21 +467,21 @@ def get_entries(): filt = SavedFilter.query.get(saved_filter_id) if filt and (filt.user_id == current_user.id or (filt.is_shared and current_user.is_admin)): payload = filt.payload or {} - if 'project_id' in payload: - query = query.filter(TimeEntry.project_id == int(payload['project_id'])) - if 'user_id' in payload and current_user.is_admin: - query = query.filter(TimeEntry.user_id == int(payload['user_id'])) - if 'billable' in payload: - query = query.filter(TimeEntry.billable == bool(payload['billable'])) - if 'tag' in payload and payload['tag']: + if "project_id" in payload: + query = query.filter(TimeEntry.project_id == int(payload["project_id"])) + if "user_id" in payload and current_user.is_admin: + query = query.filter(TimeEntry.user_id == int(payload["user_id"])) + if "billable" in payload: + query = query.filter(TimeEntry.billable == bool(payload["billable"])) + if "tag" in payload and payload["tag"]: query = query.filter(TimeEntry.tags.ilike(f"%{payload['tag']}%")) - + # Filter by user (if admin or own entries) if user_id and current_user.is_admin: query = query.filter(TimeEntry.user_id == user_id) elif not current_user.is_admin: query = query.filter(TimeEntry.user_id == current_user.id) - + # Filter by project if project_id: query = query.filter(TimeEntry.project_id == project_id) @@ -482,30 +490,29 @@ def get_entries(): if tag: like = f"%{tag}%" query = query.filter(TimeEntry.tags.ilike(like)) - - entries = query.order_by(TimeEntry.start_time.desc()).paginate( - page=page, - per_page=per_page, - error_out=False - ) - + + entries = query.order_by(TimeEntry.start_time.desc()).paginate(page=page, per_page=per_page, error_out=False) + # Ensure frontend receives project_name like other endpoints entries_payload = [] for entry in entries.items: e = entry.to_dict() - e['project_name'] = e.get('project') or (entry.project.name if entry.project else None) + e["project_name"] = e.get("project") or (entry.project.name if entry.project else None) entries_payload.append(e) - return jsonify({ - 'entries': entries_payload, - 'total': entries.total, - 'pages': entries.pages, - 'current_page': entries.page, - 'has_next': entries.has_next, - 'has_prev': entries.has_prev - }) + return jsonify( + { + "entries": entries_payload, + "total": entries.total, + "pages": entries.pages, + "current_page": entries.page, + "has_next": entries.has_next, + "has_prev": entries.has_prev, + } + ) + -@api_bp.route('/api/projects//burndown') +@api_bp.route("/api/projects//burndown") @login_required def project_burndown(project_id): """Return burn-down data for a given project. @@ -517,7 +524,7 @@ def project_burndown(project_id): if not current_user.is_admin: has_entries = db.session.query(TimeEntry.id).filter_by(user_id=current_user.id, project_id=project_id).first() if not has_entries: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 # Date range: last 30 days up to today end_date = datetime.utcnow().date() @@ -525,8 +532,7 @@ def project_burndown(project_id): # Fetch entries in range entries = ( - TimeEntry.query - .filter(TimeEntry.project_id == project_id) + TimeEntry.query.filter(TimeEntry.project_id == project_id) .filter(TimeEntry.end_time.isnot(None)) .filter(TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time())) .filter(TimeEntry.start_time <= datetime.combine(end_date, datetime.max.time())) @@ -557,24 +563,27 @@ def project_burndown(project_id): estimated = float(project.estimated_hours or 0) estimate_series = [estimated for _ in labels] - return jsonify({ - 'labels': labels, - 'actual_cumulative': actual_cumulative, - 'estimated': estimate_series, - 'estimated_hours': estimated, - }) + return jsonify( + { + "labels": labels, + "actual_cumulative": actual_cumulative, + "estimated": estimate_series, + "estimated_hours": estimated, + } + ) + -@api_bp.route('/api/focus-sessions/start', methods=['POST']) +@api_bp.route("/api/focus-sessions/start", methods=["POST"]) @login_required def start_focus_session(): data = request.get_json() or {} - project_id = data.get('project_id') - task_id = data.get('task_id') - pomodoro_length = int(data.get('pomodoro_length') or 25) - short_break_length = int(data.get('short_break_length') or 5) - long_break_length = int(data.get('long_break_length') or 15) - long_break_interval = int(data.get('long_break_interval') or 4) - link_active_timer = bool(data.get('link_active_timer', True)) + project_id = data.get("project_id") + task_id = data.get("task_id") + pomodoro_length = int(data.get("pomodoro_length") or 25) + short_break_length = int(data.get("short_break_length") or 5) + long_break_length = int(data.get("long_break_length") or 15) + long_break_interval = int(data.get("long_break_interval") or 4) + link_active_timer = bool(data.get("link_active_timer", True)) time_entry_id = None if link_active_timer and current_user.active_timer: @@ -591,68 +600,73 @@ def start_focus_session(): long_break_interval=long_break_interval, ) db.session.add(fs) - if not safe_commit('start_focus_session', {'user_id': current_user.id}): - return jsonify({'error': 'Database error while starting focus session'}), 500 + if not safe_commit("start_focus_session", {"user_id": current_user.id}): + return jsonify({"error": "Database error while starting focus session"}), 500 - return jsonify({'success': True, 'session': fs.to_dict()}) + return jsonify({"success": True, "session": fs.to_dict()}) -@api_bp.route('/api/focus-sessions/finish', methods=['POST']) + +@api_bp.route("/api/focus-sessions/finish", methods=["POST"]) @login_required def finish_focus_session(): data = request.get_json() or {} - session_id = data.get('session_id') + session_id = data.get("session_id") if not session_id: - return jsonify({'error': 'session_id is required'}), 400 + return jsonify({"error": "session_id is required"}), 400 fs = FocusSession.query.get_or_404(session_id) if fs.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 fs.ended_at = datetime.utcnow() - fs.cycles_completed = int(data.get('cycles_completed') or 0) - fs.interruptions = int(data.get('interruptions') or 0) - notes = (data.get('notes') or '').strip() + fs.cycles_completed = int(data.get("cycles_completed") or 0) + fs.interruptions = int(data.get("interruptions") or 0) + notes = (data.get("notes") or "").strip() fs.notes = notes or fs.notes - if not safe_commit('finish_focus_session', {'session_id': fs.id}): - return jsonify({'error': 'Database error while finishing focus session'}), 500 - return jsonify({'success': True, 'session': fs.to_dict()}) + if not safe_commit("finish_focus_session", {"session_id": fs.id}): + return jsonify({"error": "Database error while finishing focus session"}), 500 + return jsonify({"success": True, "session": fs.to_dict()}) + -@api_bp.route('/api/focus-sessions/summary') +@api_bp.route("/api/focus-sessions/summary") @login_required def focus_sessions_summary(): """Return simple summary counts for recent focus sessions for the current user.""" - days = int(request.args.get('days', 7)) + days = int(request.args.get("days", 7)) since = datetime.utcnow() - timedelta(days=days) q = FocusSession.query.filter(FocusSession.user_id == current_user.id, FocusSession.started_at >= since) sessions = q.order_by(FocusSession.started_at.desc()).all() total = len(sessions) cycles = sum(s.cycles_completed or 0 for s in sessions) interrupts = sum(s.interruptions or 0 for s in sessions) - return jsonify({'total_sessions': total, 'cycles_completed': cycles, 'interruptions': interrupts}) + return jsonify({"total_sessions": total, "cycles_completed": cycles, "interruptions": interrupts}) -@api_bp.route('/api/recurring-blocks', methods=['GET', 'POST']) + +@api_bp.route("/api/recurring-blocks", methods=["GET", "POST"]) @login_required def recurring_blocks_list_create(): - if request.method == 'GET': - blocks = RecurringBlock.query.filter_by(user_id=current_user.id).order_by(RecurringBlock.created_at.desc()).all() - return jsonify({'blocks': [b.to_dict() for b in blocks]}) + if request.method == "GET": + blocks = ( + RecurringBlock.query.filter_by(user_id=current_user.id).order_by(RecurringBlock.created_at.desc()).all() + ) + return jsonify({"blocks": [b.to_dict() for b in blocks]}) data = request.get_json() or {} - name = (data.get('name') or '').strip() - project_id = data.get('project_id') - task_id = data.get('task_id') - recurrence = (data.get('recurrence') or 'weekly').strip() - weekdays = (data.get('weekdays') or '').strip() - start_time_local = (data.get('start_time_local') or '').strip() - end_time_local = (data.get('end_time_local') or '').strip() - starts_on = data.get('starts_on') - ends_on = data.get('ends_on') - is_active = bool(data.get('is_active', True)) - notes = (data.get('notes') or '').strip() or None - tags = (data.get('tags') or '').strip() or None - billable = bool(data.get('billable', True)) + name = (data.get("name") or "").strip() + project_id = data.get("project_id") + task_id = data.get("task_id") + recurrence = (data.get("recurrence") or "weekly").strip() + weekdays = (data.get("weekdays") or "").strip() + start_time_local = (data.get("start_time_local") or "").strip() + end_time_local = (data.get("end_time_local") or "").strip() + starts_on = data.get("starts_on") + ends_on = data.get("ends_on") + is_active = bool(data.get("is_active", True)) + notes = (data.get("notes") or "").strip() or None + tags = (data.get("tags") or "").strip() or None + billable = bool(data.get("billable", True)) if not all([name, project_id, start_time_local, end_time_local]): - return jsonify({'error': 'name, project_id, start_time_local, end_time_local are required'}), 400 + return jsonify({"error": "name, project_id, start_time_local, end_time_local are required"}), 400 block = RecurringBlock( user_id=current_user.id, @@ -676,112 +690,116 @@ def recurring_blocks_list_create(): if ends_on: block.ends_on = datetime.fromisoformat(ends_on).date() except Exception: - return jsonify({'error': 'Invalid starts_on/ends_on date format'}), 400 + return jsonify({"error": "Invalid starts_on/ends_on date format"}), 400 db.session.add(block) - if not safe_commit('create_recurring_block', {'user_id': current_user.id}): - return jsonify({'error': 'Database error while creating recurring block'}), 500 - return jsonify({'success': True, 'block': block.to_dict()}) + if not safe_commit("create_recurring_block", {"user_id": current_user.id}): + return jsonify({"error": "Database error while creating recurring block"}), 500 + return jsonify({"success": True, "block": block.to_dict()}) -@api_bp.route('/api/recurring-blocks/', methods=['PUT', 'DELETE']) + +@api_bp.route("/api/recurring-blocks/", methods=["PUT", "DELETE"]) @login_required def recurring_block_update_delete(block_id): block = RecurringBlock.query.get_or_404(block_id) if block.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 - if request.method == 'DELETE': + if request.method == "DELETE": db.session.delete(block) - if not safe_commit('delete_recurring_block', {'id': block.id}): - return jsonify({'error': 'Database error while deleting recurring block'}), 500 - return jsonify({'success': True}) + if not safe_commit("delete_recurring_block", {"id": block.id}): + return jsonify({"error": "Database error while deleting recurring block"}), 500 + return jsonify({"success": True}) data = request.get_json() or {} - for field in ['name', 'recurrence', 'weekdays', 'start_time_local', 'end_time_local', 'notes', 'tags']: + for field in ["name", "recurrence", "weekdays", "start_time_local", "end_time_local", "notes", "tags"]: if field in data: - setattr(block, field, (data.get(field) or '').strip()) - for field in ['project_id', 'task_id']: + setattr(block, field, (data.get(field) or "").strip()) + for field in ["project_id", "task_id"]: if field in data: setattr(block, field, data.get(field)) - if 'is_active' in data: - block.is_active = bool(data.get('is_active')) - if 'billable' in data: - block.billable = bool(data.get('billable')) + if "is_active" in data: + block.is_active = bool(data.get("is_active")) + if "billable" in data: + block.billable = bool(data.get("billable")) try: - if 'starts_on' in data: - block.starts_on = datetime.fromisoformat(data.get('starts_on')).date() if data.get('starts_on') else None - if 'ends_on' in data: - block.ends_on = datetime.fromisoformat(data.get('ends_on')).date() if data.get('ends_on') else None + if "starts_on" in data: + block.starts_on = datetime.fromisoformat(data.get("starts_on")).date() if data.get("starts_on") else None + if "ends_on" in data: + block.ends_on = datetime.fromisoformat(data.get("ends_on")).date() if data.get("ends_on") else None except Exception: - return jsonify({'error': 'Invalid starts_on/ends_on date format'}), 400 + return jsonify({"error": "Invalid starts_on/ends_on date format"}), 400 + + if not safe_commit("update_recurring_block", {"id": block.id}): + return jsonify({"error": "Database error while updating recurring block"}), 500 + return jsonify({"success": True, "block": block.to_dict()}) - if not safe_commit('update_recurring_block', {'id': block.id}): - return jsonify({'error': 'Database error while updating recurring block'}), 500 - return jsonify({'success': True, 'block': block.to_dict()}) -@api_bp.route('/api/saved-filters', methods=['GET', 'POST']) +@api_bp.route("/api/saved-filters", methods=["GET", "POST"]) @login_required def saved_filters_list_create(): - if request.method == 'GET': - scope = (request.args.get('scope') or 'global').strip() + if request.method == "GET": + scope = (request.args.get("scope") or "global").strip() items = SavedFilter.query.filter_by(user_id=current_user.id, scope=scope).order_by(SavedFilter.name.asc()).all() - return jsonify({'filters': [f.to_dict() for f in items]}) + return jsonify({"filters": [f.to_dict() for f in items]}) data = request.get_json() or {} - name = (data.get('name') or '').strip() - scope = (data.get('scope') or 'global').strip() - payload = data.get('payload') or {} - is_shared = bool(data.get('is_shared', False)) + name = (data.get("name") or "").strip() + scope = (data.get("scope") or "global").strip() + payload = data.get("payload") or {} + is_shared = bool(data.get("is_shared", False)) if not name: - return jsonify({'error': 'name is required'}), 400 + return jsonify({"error": "name is required"}), 400 filt = SavedFilter(user_id=current_user.id, name=name, scope=scope, payload=payload, is_shared=is_shared) db.session.add(filt) - if not safe_commit('create_saved_filter', {'name': name, 'scope': scope}): - return jsonify({'error': 'Database error while creating saved filter'}), 500 - return jsonify({'success': True, 'filter': filt.to_dict()}) + if not safe_commit("create_saved_filter", {"name": name, "scope": scope}): + return jsonify({"error": "Database error while creating saved filter"}), 500 + return jsonify({"success": True, "filter": filt.to_dict()}) -@api_bp.route('/api/saved-filters/', methods=['DELETE']) + +@api_bp.route("/api/saved-filters/", methods=["DELETE"]) @login_required def delete_saved_filter(filter_id): filt = SavedFilter.query.get_or_404(filter_id) if filt.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 db.session.delete(filt) - if not safe_commit('delete_saved_filter', {'id': filt.id}): - return jsonify({'error': 'Database error while deleting saved filter'}), 500 - return jsonify({'success': True}) + if not safe_commit("delete_saved_filter", {"id": filt.id}): + return jsonify({"error": "Database error while deleting saved filter"}), 500 + return jsonify({"success": True}) + -@api_bp.route('/api/entries', methods=['POST']) +@api_bp.route("/api/entries", methods=["POST"]) @login_required def create_entry(): """Create a finished time entry (used by calendar drag-create).""" data = request.get_json() or {} - project_id = data.get('project_id') - task_id = data.get('task_id') - start_time_str = data.get('start_time') - end_time_str = data.get('end_time') - notes = (data.get('notes') or '').strip() or None - tags = (data.get('tags') or '').strip() or None - billable = bool(data.get('billable', True)) + project_id = data.get("project_id") + task_id = data.get("task_id") + start_time_str = data.get("start_time") + end_time_str = data.get("end_time") + notes = (data.get("notes") or "").strip() or None + tags = (data.get("tags") or "").strip() or None + billable = bool(data.get("billable", True)) if not (project_id and start_time_str and end_time_str): - return jsonify({'error': 'project_id, start_time, end_time are required'}), 400 + return jsonify({"error": "project_id, start_time, end_time are required"}), 400 # Validate project - project = Project.query.filter_by(id=project_id, status='active').first() + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - return jsonify({'error': 'Invalid project'}), 400 + return jsonify({"error": "Invalid project"}), 400 if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - return jsonify({'error': 'Invalid task for selected project'}), 400 + return jsonify({"error": "Invalid task for selected project"}), 400 def parse_iso_local(s: str): try: ts = s.strip() - if ts.endswith('Z'): - ts = ts[:-1] + '+00:00' + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" dt = datetime.fromisoformat(ts) if dt.tzinfo is not None: return utc_to_local(dt).replace(tzinfo=None) @@ -792,61 +810,62 @@ def parse_iso_local(s: str): start_dt = parse_iso_local(start_time_str) end_dt = parse_iso_local(end_time_str) if not (start_dt and end_dt) or end_dt <= start_dt: - return jsonify({'error': 'Invalid start/end time'}), 400 + return jsonify({"error": "Invalid start/end time"}), 400 entry = TimeEntry( - user_id=current_user.id if not current_user.is_admin else (data.get('user_id') or current_user.id), + user_id=current_user.id if not current_user.is_admin else (data.get("user_id") or current_user.id), project_id=project_id, task_id=task_id, start_time=start_dt, end_time=end_dt, notes=notes, tags=tags, - source='manual', - billable=billable + source="manual", + billable=billable, ) db.session.add(entry) - if not safe_commit('api_create_entry', {'project_id': project_id}): - return jsonify({'error': 'Database error while creating entry'}), 500 + if not safe_commit("api_create_entry", {"project_id": project_id}): + return jsonify({"error": "Database error while creating entry"}), 500 payload = entry.to_dict() - payload['project_name'] = entry.project.name if entry.project else None - return jsonify({'success': True, 'entry': payload}) + payload["project_name"] = entry.project.name if entry.project else None + return jsonify({"success": True, "entry": payload}) + -@api_bp.route('/api/entries/bulk', methods=['POST']) +@api_bp.route("/api/entries/bulk", methods=["POST"]) @login_required def bulk_entries_action(): """Perform bulk actions on time entries: delete, set billable, add/remove tag.""" data = request.get_json() or {} - entry_ids = data.get('entry_ids') or [] - action = (data.get('action') or '').strip() - value = data.get('value') + entry_ids = data.get("entry_ids") or [] + action = (data.get("action") or "").strip() + value = data.get("value") if not entry_ids or not isinstance(entry_ids, list): - return jsonify({'error': 'entry_ids must be a non-empty list'}), 400 - if action not in {'delete', 'set_billable', 'add_tag', 'remove_tag'}: - return jsonify({'error': 'Unsupported action'}), 400 + return jsonify({"error": "entry_ids must be a non-empty list"}), 400 + if action not in {"delete", "set_billable", "add_tag", "remove_tag"}: + return jsonify({"error": "Unsupported action"}), 400 # Load entries with permission checks q = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids)) entries = q.all() if not entries: - return jsonify({'error': 'No entries found'}), 404 + return jsonify({"error": "No entries found"}), 404 # Permission: non-admins can only modify own entries if not current_user.is_admin: for e in entries: if e.user_id != current_user.id: - return jsonify({'error': 'Access denied for one or more entries'}), 403 + return jsonify({"error": "Access denied for one or more entries"}), 403 affected = 0 - if action == 'delete': + if action == "delete": for e in entries: if e.is_active: continue db.session.delete(e) affected += 1 - elif action == 'set_billable': + elif action == "set_billable": flag = bool(value) for e in entries: if e.is_active: @@ -854,58 +873,59 @@ def bulk_entries_action(): e.billable = flag e.updated_at = local_now() affected += 1 - elif action in {'add_tag', 'remove_tag'}: - tag = (value or '').strip() + elif action in {"add_tag", "remove_tag"}: + tag = (value or "").strip() if not tag: - return jsonify({'error': 'Tag value is required'}), 400 + return jsonify({"error": "Tag value is required"}), 400 for e in entries: if e.is_active: continue tags = set(e.tag_list) - if action == 'add_tag': + if action == "add_tag": tags.add(tag) else: tags.discard(tag) - e.tags = ', '.join(sorted(tags)) if tags else None + e.tags = ", ".join(sorted(tags)) if tags else None e.updated_at = local_now() affected += 1 if affected > 0: - if not safe_commit('api_bulk_entries', {'action': action, 'count': affected}): - return jsonify({'error': 'Database error during bulk operation'}), 500 + if not safe_commit("api_bulk_entries", {"action": action, "count": affected}): + return jsonify({"error": "Database error during bulk operation"}), 500 else: db.session.rollback() - return jsonify({'success': True, 'affected': affected}) + return jsonify({"success": True, "affected": affected}) -@api_bp.route('/api/calendar/events') + +@api_bp.route("/api/calendar/events") @login_required def calendar_events(): """Return calendar events, tasks, and time entries for the current user in a date range.""" from app.models import CalendarEvent as CalendarEventModel - - start = request.args.get('start') - end = request.args.get('end') - include_tasks = request.args.get('include_tasks', 'true').lower() == 'true' - include_time_entries = request.args.get('include_time_entries', 'true').lower() == 'true' - project_id = request.args.get('project_id', type=int) - task_id = request.args.get('task_id', type=int) - tags = request.args.get('tags', '').strip() - + + start = request.args.get("start") + end = request.args.get("end") + include_tasks = request.args.get("include_tasks", "true").lower() == "true" + include_time_entries = request.args.get("include_time_entries", "true").lower() == "true" + project_id = request.args.get("project_id", type=int) + task_id = request.args.get("task_id", type=int) + tags = request.args.get("tags", "").strip() + # Get user_id from query param (admins only) or default to current user - if current_user.is_admin and request.args.get('user_id'): - user_id = request.args.get('user_id', type=int) + if current_user.is_admin and request.args.get("user_id"): + user_id = request.args.get("user_id", type=int) else: user_id = current_user.id - + if not (start and end): - return jsonify({'error': 'start and end are required'}), 400 + return jsonify({"error": "start and end are required"}), 400 def parse_iso(s: str): try: ts = s.strip() - if ts.endswith('Z'): - ts = ts[:-1] + '+00:00' + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" dt = datetime.fromisoformat(ts) if dt.tzinfo is not None: return utc_to_local(dt).replace(tzinfo=None) @@ -916,7 +936,7 @@ def parse_iso(s: str): start_dt = parse_iso(start) end_dt = parse_iso(end) if not (start_dt and end_dt): - return jsonify({'error': 'Invalid date range'}), 400 + return jsonify({"error": "Invalid date range"}), 400 # Get all calendar items using the new method result = CalendarEventModel.get_events_in_range( @@ -924,311 +944,334 @@ def parse_iso(s: str): start_date=start_dt, end_date=end_dt, include_tasks=include_tasks, - include_time_entries=include_time_entries + include_time_entries=include_time_entries, ) - + # Color scheme for projects (deterministic based on project ID) def get_project_color(project_id): colors = [ - '#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6', - '#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16' + "#3b82f6", + "#ef4444", + "#10b981", + "#f59e0b", + "#8b5cf6", + "#ec4899", + "#14b8a6", + "#f97316", + "#6366f1", + "#84cc16", ] - return colors[project_id % len(colors)] if project_id else '#6b7280' - + return colors[project_id % len(colors)] if project_id else "#6b7280" + # Apply filters and format time entries time_entries = [] - for e in result.get('time_entries', []): + for e in result.get("time_entries", []): # Apply filters - if project_id and e.get('projectId') != project_id: + if project_id and e.get("projectId") != project_id: continue - if task_id and e.get('taskId') != task_id: + if task_id and e.get("taskId") != task_id: continue - if tags and tags.lower() not in (e.get('notes') or '').lower(): + if tags and tags.lower() not in (e.get("notes") or "").lower(): continue - - time_entries.append({ - 'id': e['id'], - 'title': e['title'], - 'start': e['start'], - 'end': e['end'], - 'editable': True, - 'allDay': False, - 'backgroundColor': get_project_color(e.get('projectId')), - 'borderColor': get_project_color(e.get('projectId')), - 'extendedProps': { - **e, - 'item_type': 'time_entry' + + time_entries.append( + { + "id": e["id"], + "title": e["title"], + "start": e["start"], + "end": e["end"], + "editable": True, + "allDay": False, + "backgroundColor": get_project_color(e.get("projectId")), + "borderColor": get_project_color(e.get("projectId")), + "extendedProps": {**e, "item_type": "time_entry"}, } - }) - + ) + # Format tasks tasks = [] - for t in result.get('tasks', []): - tasks.append({ - 'id': t['id'], - 'title': t['title'], - 'start': t['dueDate'], - 'end': t['dueDate'], - 'allDay': True, - 'editable': False, - 'backgroundColor': '#f59e0b', - 'borderColor': '#f59e0b', - 'extendedProps': { - **t, - 'item_type': 'task' + for t in result.get("tasks", []): + tasks.append( + { + "id": t["id"], + "title": t["title"], + "start": t["dueDate"], + "end": t["dueDate"], + "allDay": True, + "editable": False, + "backgroundColor": "#f59e0b", + "borderColor": "#f59e0b", + "extendedProps": {**t, "item_type": "task"}, } - }) - + ) + # Format calendar events events = [] - for ev in result.get('events', []): - events.append({ - 'id': ev['id'], - 'title': ev['title'], - 'start': ev['start'], - 'end': ev['end'], - 'allDay': ev.get('allDay', False), - 'editable': True, - 'backgroundColor': ev.get('color', '#3b82f6'), - 'borderColor': ev.get('color', '#3b82f6'), - 'extendedProps': { - **ev, - 'item_type': 'event' + for ev in result.get("events", []): + events.append( + { + "id": ev["id"], + "title": ev["title"], + "start": ev["start"], + "end": ev["end"], + "allDay": ev.get("allDay", False), + "editable": True, + "backgroundColor": ev.get("color", "#3b82f6"), + "borderColor": ev.get("color", "#3b82f6"), + "extendedProps": {**ev, "item_type": "event"}, } - }) - + ) + # Combine all items all_items = events + tasks + time_entries - - return jsonify({ - 'events': all_items, - 'summary': { - 'calendar_events': len(events), - 'tasks': len(tasks), - 'time_entries': len(time_entries) + + return jsonify( + { + "events": all_items, + "summary": {"calendar_events": len(events), "tasks": len(tasks), "time_entries": len(time_entries)}, } - }) + ) -@api_bp.route('/api/calendar/export') + +@api_bp.route("/api/calendar/export") @login_required def calendar_export(): """Export calendar events to iCal or CSV format.""" - start = request.args.get('start') - end = request.args.get('end') - format_type = request.args.get('format', 'ical').lower() - project_id = request.args.get('project_id', type=int) - + start = request.args.get("start") + end = request.args.get("end") + format_type = request.args.get("format", "ical").lower() + project_id = request.args.get("project_id", type=int) + if not (start and end): - return jsonify({'error': 'start and end are required'}), 400 - + return jsonify({"error": "start and end are required"}), 400 + def parse_iso(s: str): try: ts = s.strip() - if ts.endswith('Z'): - ts = ts[:-1] + '+00:00' + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" dt = datetime.fromisoformat(ts) if dt.tzinfo is not None: return utc_to_local(dt).replace(tzinfo=None) return dt except Exception: return None - + start_dt = parse_iso(start) end_dt = parse_iso(end) if not (start_dt and end_dt): - return jsonify({'error': 'Invalid date range'}), 400 - + return jsonify({"error": "Invalid date range"}), 400 + # Build query q = TimeEntry.query.filter(TimeEntry.user_id == current_user.id) q = q.filter(TimeEntry.start_time < end_dt, (TimeEntry.end_time.is_(None)) | (TimeEntry.end_time > start_dt)) if project_id: q = q.filter(TimeEntry.project_id == project_id) - + items = q.order_by(TimeEntry.start_time.asc()).all() - - if format_type == 'csv': + + if format_type == "csv": import csv from io import StringIO - + output = StringIO() writer = csv.writer(output) - writer.writerow(['Date', 'Start Time', 'End Time', 'Project', 'Task', 'Duration (hours)', 'Notes', 'Tags', 'Billable']) - + writer.writerow( + ["Date", "Start Time", "End Time", "Project", "Task", "Duration (hours)", "Notes", "Tags", "Billable"] + ) + for entry in items: start_local = convert_app_datetime_to_user(entry.start_time, user=current_user) end_local = convert_app_datetime_to_user(entry.end_time, user=current_user) if entry.end_time else None - writer.writerow([ - start_local.strftime('%Y-%m-%d') if start_local else '', - start_local.strftime('%H:%M') if start_local else '', - end_local.strftime('%H:%M') if end_local else 'Active', - entry.project.name if entry.project else '', - entry.task.name if entry.task else '', - f"{entry.duration_hours:.2f}" if entry.duration_hours else '', - entry.notes or '', - entry.tags or '', - 'Yes' if entry.billable else 'No' - ]) - + writer.writerow( + [ + start_local.strftime("%Y-%m-%d") if start_local else "", + start_local.strftime("%H:%M") if start_local else "", + end_local.strftime("%H:%M") if end_local else "Active", + entry.project.name if entry.project else "", + entry.task.name if entry.task else "", + f"{entry.duration_hours:.2f}" if entry.duration_hours else "", + entry.notes or "", + entry.tags or "", + "Yes" if entry.billable else "No", + ] + ) + response = make_response(output.getvalue()) - response.headers['Content-Type'] = 'text/csv' - response.headers['Content-Disposition'] = f'attachment; filename=calendar_export_{start_dt.strftime("%Y%m%d")}_to_{end_dt.strftime("%Y%m%d")}.csv' + response.headers["Content-Type"] = "text/csv" + response.headers["Content-Disposition"] = ( + f'attachment; filename=calendar_export_{start_dt.strftime("%Y%m%d")}_to_{end_dt.strftime("%Y%m%d")}.csv' + ) return response - - elif format_type == 'ical': + + elif format_type == "ical": # Generate iCal format ical_lines = [ - 'BEGIN:VCALENDAR', - 'VERSION:2.0', - 'PRODID:-//TimeTracker//Calendar Export//EN', - 'CALSCALE:GREGORIAN', - 'METHOD:PUBLISH' + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//TimeTracker//Calendar Export//EN", + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", ] - + for entry in items: if not entry.end_time: continue - + start_local = convert_app_datetime_to_user(entry.start_time, user=current_user) end_local = convert_app_datetime_to_user(entry.end_time, user=current_user) - - title = entry.project.name if entry.project else 'Time Entry' + + title = entry.project.name if entry.project else "Time Entry" if entry.task: - title += f' - {entry.task.name}' - + title += f" - {entry.task.name}" + description = [] if entry.notes: - description.append(f'Notes: {entry.notes}') + description.append(f"Notes: {entry.notes}") if entry.tags: - description.append(f'Tags: {entry.tags}') + description.append(f"Tags: {entry.tags}") description.append(f'Billable: {"Yes" if entry.billable else "No"}') - - ical_lines.extend([ - 'BEGIN:VEVENT', - f'UID:{entry.id}@timetracker', - f'DTSTAMP:{datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")}', - f'DTSTART:{start_local.strftime("%Y%m%dT%H%M%S") if start_local else entry.start_time.strftime("%Y%m%dT%H%M%S")}', - f'DTEND:{end_local.strftime("%Y%m%dT%H%M%S") if end_local else entry.end_time.strftime("%Y%m%dT%H%M%S")}', - f'SUMMARY:{title}', - f'DESCRIPTION:{" | ".join(description)}', - 'END:VEVENT' - ]) - - ical_lines.append('END:VCALENDAR') - - response = make_response('\r\n'.join(ical_lines)) - response.headers['Content-Type'] = 'text/calendar' - response.headers['Content-Disposition'] = f'attachment; filename=calendar_export_{start_dt.strftime("%Y%m%d")}_to_{end_dt.strftime("%Y%m%d")}.ics' + + ical_lines.extend( + [ + "BEGIN:VEVENT", + f"UID:{entry.id}@timetracker", + f'DTSTAMP:{datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")}', + f'DTSTART:{start_local.strftime("%Y%m%dT%H%M%S") if start_local else entry.start_time.strftime("%Y%m%dT%H%M%S")}', + f'DTEND:{end_local.strftime("%Y%m%dT%H%M%S") if end_local else entry.end_time.strftime("%Y%m%dT%H%M%S")}', + f"SUMMARY:{title}", + f'DESCRIPTION:{" | ".join(description)}', + "END:VEVENT", + ] + ) + + ical_lines.append("END:VCALENDAR") + + response = make_response("\r\n".join(ical_lines)) + response.headers["Content-Type"] = "text/calendar" + response.headers["Content-Disposition"] = ( + f'attachment; filename=calendar_export_{start_dt.strftime("%Y%m%d")}_to_{end_dt.strftime("%Y%m%d")}.ics' + ) return response - - return jsonify({'error': 'Invalid format. Use "ical" or "csv"'}), 400 -@api_bp.route('/api/projects') + return jsonify({"error": 'Invalid format. Use "ical" or "csv"'}), 400 + + +@api_bp.route("/api/projects") @login_required def get_projects(): """Get active projects""" - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return jsonify({ - 'projects': [project.to_dict() for project in projects] - }) + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return jsonify({"projects": [project.to_dict() for project in projects]}) + -@api_bp.route('/api/projects//tasks') +@api_bp.route("/api/projects//tasks") @login_required def get_project_tasks(project_id): """Get tasks for a specific project (excluding done and cancelled tasks)""" # Check if project exists and is active - project = Project.query.filter_by(id=project_id, status='active').first() + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - return jsonify({'error': 'Project not found or inactive'}), 404 - + return jsonify({"error": "Project not found or inactive"}), 404 + # Get active tasks for the project (exclude done and cancelled) - tasks = Task.query.filter_by(project_id=project_id).filter( - Task.status.in_(['todo', 'in_progress', 'review']) - ).order_by(Task.name).all() - - return jsonify({ - 'success': True, - 'tasks': [{ - 'id': task.id, - 'name': task.name, - 'description': task.description, - 'status': task.status, - 'priority': task.priority - } for task in tasks] - }) + tasks = ( + Task.query.filter_by(project_id=project_id) + .filter(Task.status.in_(["todo", "in_progress", "review"])) + .order_by(Task.name) + .all() + ) + + return jsonify( + { + "success": True, + "tasks": [ + { + "id": task.id, + "name": task.name, + "description": task.description, + "status": task.status, + "priority": task.priority, + } + for task in tasks + ], + } + ) + # Fetch a single time entry (details for edit modal) -@api_bp.route('/api/entry/', methods=['GET']) +@api_bp.route("/api/entry/", methods=["GET"]) @login_required def get_entry(entry_id): entry = TimeEntry.query.get_or_404(entry_id) if entry.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 payload = entry.to_dict() - payload['project_name'] = entry.project.name if entry.project else None + payload["project_name"] = entry.project.name if entry.project else None return jsonify(payload) -@api_bp.route('/api/users') + +@api_bp.route("/api/users") @login_required def get_users(): """Get active users (admin only)""" if not current_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + users = User.query.filter_by(is_active=True).order_by(User.username).all() - return jsonify({ - 'users': [user.to_dict() for user in users] - }) + return jsonify({"users": [user.to_dict() for user in users]}) + -@api_bp.route('/api/stats') +@api_bp.route("/api/stats") @login_required def get_stats(): """Get user statistics""" # Get date range end_date = datetime.utcnow() start_date = end_date - timedelta(days=30) - + # Calculate statistics today_hours = TimeEntry.get_total_hours_for_period( - start_date=end_date.date(), - user_id=current_user.id if not current_user.is_admin else None + start_date=end_date.date(), user_id=current_user.id if not current_user.is_admin else None ) - + week_hours = TimeEntry.get_total_hours_for_period( - start_date=end_date.date() - timedelta(days=7), - user_id=current_user.id if not current_user.is_admin else None + start_date=end_date.date() - timedelta(days=7), user_id=current_user.id if not current_user.is_admin else None ) - + month_hours = TimeEntry.get_total_hours_for_period( - start_date=start_date.date(), - user_id=current_user.id if not current_user.is_admin else None + start_date=start_date.date(), user_id=current_user.id if not current_user.is_admin else None + ) + + return jsonify( + { + "today_hours": today_hours, + "week_hours": week_hours, + "month_hours": month_hours, + "total_hours": current_user.total_hours, + } ) - - return jsonify({ - 'today_hours': today_hours, - 'week_hours': week_hours, - 'month_hours': month_hours, - 'total_hours': current_user.total_hours - }) - -@api_bp.route('/api/entry/', methods=['PUT']) + + +@api_bp.route("/api/entry/", methods=["PUT"]) @login_required def update_entry(entry_id): """Update 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 - + return jsonify({"error": "Access denied"}), 403 + data = request.get_json() or {} # Optional: project change (admin only) - new_project_id = data.get('project_id') + 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() + project = Project.query.filter_by(id=new_project_id, status="active").first() if not project: - return jsonify({'error': 'Invalid project'}), 400 + return jsonify({"error": "Invalid project"}), 400 entry.project_id = new_project_id # Optional: start/end time updates (admin only for safety) @@ -1237,10 +1280,10 @@ def parse_dt_local(dt_str): if not dt_str: return None try: - if 'T' in dt_str: - date_part, time_part = dt_str.split('T', 1) + if "T" in dt_str: + date_part, time_part = dt_str.split("T", 1) else: - date_part, time_part = dt_str.split(' ', 1) + date_part, time_part = dt_str.split(" ", 1) # Parse as UTC-aware then convert to local naive to match model storage parsed_utc = parse_local_datetime(date_part, time_part) parsed_local_aware = utc_to_local(parsed_utc) @@ -1249,25 +1292,25 @@ def parse_dt_local(dt_str): return None if current_user.is_admin: - start_time_str = data.get('start_time') - end_time_str = data.get('end_time') + 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 + 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: + 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 + 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 + return jsonify({"error": "End time must be after start time"}), 400 entry.end_time = parsed_end # Recalculate duration entry.calculate_duration() @@ -1275,133 +1318,140 @@ def parse_dt_local(dt_str): # 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) + 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 + 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 "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 "tags" in data: + entry.tags = data["tags"].strip() if data["tags"] else None - if 'billable' in data: - entry.billable = bool(data['billable']) + if "billable" in data: + entry.billable = bool(data["billable"]) # 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 + if not safe_commit("api_update_entry", {"entry_id": entry_id}): + return jsonify({"error": "Database error while updating entry"}), 500 payload = entry.to_dict() - payload['project_name'] = entry.project.name if entry.project else None - return jsonify({'success': True, 'entry': payload}) + payload["project_name"] = entry.project.name if entry.project else None + return jsonify({"success": True, "entry": payload}) + -@api_bp.route('/api/entry/', methods=['DELETE']) +@api_bp.route("/api/entry/", methods=["DELETE"]) @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 - + 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 - + return jsonify({"error": "Cannot delete active timer"}), 400 + db.session.delete(entry) db.session.commit() - - return jsonify({'success': True}) + + return jsonify({"success": True}) + # ================================ # Editor image uploads # ================================ -ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} + def allowed_image_file(filename: str) -> bool: - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_IMAGE_EXTENSIONS + def get_editor_upload_folder() -> str: - upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'editor') + upload_folder = os.path.join(current_app.root_path, "static", "uploads", "editor") os.makedirs(upload_folder, exist_ok=True) return upload_folder -@api_bp.route('/api/uploads/images', methods=['POST']) + +@api_bp.route("/api/uploads/images", methods=["POST"]) @login_required def upload_editor_image(): """Handle image uploads from the markdown editor.""" - if 'image' not in request.files: - return jsonify({'error': 'No image provided'}), 400 - file = request.files['image'] - if not file or file.filename == '': - return jsonify({'error': 'No image provided'}), 400 + if "image" not in request.files: + return jsonify({"error": "No image provided"}), 400 + file = request.files["image"] + if not file or file.filename == "": + return jsonify({"error": "No image provided"}), 400 if not allowed_image_file(file.filename): - return jsonify({'error': 'Invalid file type'}), 400 + return jsonify({"error": "Invalid file type"}), 400 filename = secure_filename(file.filename) - ext = filename.rsplit('.', 1)[1].lower() + ext = filename.rsplit(".", 1)[1].lower() unique_name = f"editor_{uuid.uuid4().hex[:12]}.{ext}" folder = get_editor_upload_folder() path = os.path.join(folder, unique_name) file.save(path) url = f"/uploads/editor/{unique_name}" - return jsonify({'success': True, 'url': url}) + return jsonify({"success": True, "url": url}) -@api_bp.route('/uploads/editor/') + +@api_bp.route("/uploads/editor/") def serve_editor_image(filename): """Serve uploaded editor images from static/uploads/editor.""" folder = get_editor_upload_folder() return send_from_directory(folder, filename) + # ================================ # Activity Feed API # ================================ -@api_bp.route('/api/activities') + +@api_bp.route("/api/activities") @login_required def get_activities(): """Get recent activities with filtering""" from app.models import Activity from sqlalchemy import and_ - + # Get query parameters - limit = request.args.get('limit', 50, type=int) - page = request.args.get('page', 1, type=int) - user_id = request.args.get('user_id', type=int) - entity_type = request.args.get('entity_type', '').strip() - action = request.args.get('action', '').strip() - start_date = request.args.get('start_date', '').strip() - end_date = request.args.get('end_date', '').strip() - + limit = request.args.get("limit", 50, type=int) + page = request.args.get("page", 1, type=int) + user_id = request.args.get("user_id", type=int) + entity_type = request.args.get("entity_type", "").strip() + action = request.args.get("action", "").strip() + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + # Build query query = Activity.query - + # Filter by user (admins can see all, users see only their own) if not current_user.is_admin: query = query.filter_by(user_id=current_user.id) elif user_id: query = query.filter_by(user_id=user_id) - + # Filter by entity type if entity_type: query = query.filter_by(entity_type=entity_type) - + # Filter by action if action: query = query.filter_by(action=action) - + # Filter by date range if start_date: try: @@ -1409,95 +1459,87 @@ def get_activities(): query = query.filter(Activity.created_at >= start_dt) except ValueError: pass - + if end_date: try: end_dt = datetime.fromisoformat(end_date) query = query.filter(Activity.created_at <= end_dt) except ValueError: pass - + # Get total count total = query.count() - + # Apply ordering and pagination - activities = query.order_by(Activity.created_at.desc()).paginate( - page=page, - per_page=limit, - error_out=False + activities = query.order_by(Activity.created_at.desc()).paginate(page=page, per_page=limit, error_out=False) + + return jsonify( + { + "activities": [a.to_dict() for a in activities.items], + "total": total, + "pages": activities.pages, + "current_page": activities.page, + "has_next": activities.has_next, + "has_prev": activities.has_prev, + } ) - - return jsonify({ - 'activities': [a.to_dict() for a in activities.items], - 'total': total, - 'pages': activities.pages, - 'current_page': activities.page, - 'has_next': activities.has_next, - 'has_prev': activities.has_prev - }) - -@api_bp.route('/api/dashboard/stats') + + +@api_bp.route("/api/dashboard/stats") @login_required def dashboard_stats(): """Get dashboard statistics for real-time updates""" from app.models import TimeEntry from datetime import datetime, timedelta - + today = datetime.utcnow().date() week_start = today - timedelta(days=today.weekday()) month_start = today.replace(day=1) - - today_hours = TimeEntry.get_total_hours_for_period( - start_date=today, - user_id=current_user.id - ) - - week_hours = TimeEntry.get_total_hours_for_period( - start_date=week_start, - user_id=current_user.id - ) - - month_hours = TimeEntry.get_total_hours_for_period( - start_date=month_start, - user_id=current_user.id + + today_hours = TimeEntry.get_total_hours_for_period(start_date=today, user_id=current_user.id) + + week_hours = TimeEntry.get_total_hours_for_period(start_date=week_start, user_id=current_user.id) + + month_hours = TimeEntry.get_total_hours_for_period(start_date=month_start, user_id=current_user.id) + + return jsonify( + { + "success": True, + "today_hours": float(today_hours), + "week_hours": float(week_hours), + "month_hours": float(month_hours), + } ) - - return jsonify({ - 'success': True, - 'today_hours': float(today_hours), - 'week_hours': float(week_hours), - 'month_hours': float(month_hours) - }) - -@api_bp.route('/api/dashboard/sparklines') + + +@api_bp.route("/api/dashboard/sparklines") @login_required def dashboard_sparklines(): """Get sparkline data for dashboard widgets""" from app.models import TimeEntry from datetime import datetime, timedelta from sqlalchemy import func - + # Get last 7 days of data seven_days_ago = datetime.utcnow() - timedelta(days=7) - + # Get daily totals for last 7 days - daily_totals = db.session.query( - func.date(TimeEntry.start_time).label('date'), - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).filter( - TimeEntry.user_id == current_user.id, - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= seven_days_ago - ).group_by( - func.date(TimeEntry.start_time) - ).order_by( - func.date(TimeEntry.start_time) - ).all() - + daily_totals = ( + db.session.query( + func.date(TimeEntry.start_time).label("date"), func.sum(TimeEntry.duration_seconds).label("total_seconds") + ) + .filter( + TimeEntry.user_id == current_user.id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= seven_days_ago + ) + .group_by(func.date(TimeEntry.start_time)) + .order_by(func.date(TimeEntry.start_time)) + .all() + ) + # Convert to hours and create array hours_data = [] for i in range(7): - date = datetime.utcnow().date() - timedelta(days=6-i) + date = datetime.utcnow().date() - timedelta(days=6 - i) matching = next((d for d in daily_totals if d.date == date), None) if matching: # total_seconds is already in seconds (Integer), convert to hours @@ -1505,168 +1547,162 @@ def dashboard_sparklines(): else: hours = 0 hours_data.append(round(hours, 1)) - - return jsonify({ - 'success': True, - 'today': hours_data, - 'week': hours_data, # Same data for now - 'month': hours_data # Same data for now - }) - -@api_bp.route('/api/summary/today') + + return jsonify( + { + "success": True, + "today": hours_data, + "week": hours_data, # Same data for now + "month": hours_data, # Same data for now + } + ) + + +@api_bp.route("/api/summary/today") @login_required def summary_today(): """Get today's time tracking summary for daily summary notification""" from app.models import TimeEntry, Project from datetime import datetime, timedelta from sqlalchemy import func, distinct - + today = datetime.utcnow().date() - + # Get today's time entries for current user entries = TimeEntry.query.filter( - TimeEntry.user_id == current_user.id, - func.date(TimeEntry.start_time) == today, - TimeEntry.end_time.isnot(None) + TimeEntry.user_id == current_user.id, func.date(TimeEntry.start_time) == today, TimeEntry.end_time.isnot(None) ).all() - + # Calculate total hours total_hours = sum((entry.duration_hours or 0) for entry in entries) - + # Count unique projects project_ids = set(entry.project_id for entry in entries if entry.project_id) project_count = len(project_ids) - - return jsonify({ - 'hours': round(total_hours, 2), - 'projects': project_count - }) -@api_bp.route('/api/activity/timeline') + return jsonify({"hours": round(total_hours, 2), "projects": project_count}) + + +@api_bp.route("/api/activity/timeline") @login_required def activity_timeline(): """Get activity timeline for dashboard""" from app.models import Activity from datetime import datetime, timedelta - + # Get activities from last 7 days seven_days_ago = datetime.utcnow() - timedelta(days=7) - query = Activity.query.filter( - Activity.created_at >= seven_days_ago - ) - + query = Activity.query.filter(Activity.created_at >= seven_days_ago) + # Filter by user if not admin if not current_user.is_admin: query = query.filter_by(user_id=current_user.id) - + activities = query.order_by(Activity.created_at.desc()).limit(20).all() - + activities_data = [] for activity in activities: - activities_data.append({ - 'id': activity.id, - 'type': activity.entity_type or 'default', - 'action': activity.action or 'unknown', - 'description': activity.description or 'Activity', - 'created_at': activity.created_at.isoformat() if activity.created_at else None - }) - - return jsonify({ - 'success': True, - 'activities': activities_data - }) - -@api_bp.route('/api/activities/stats') + activities_data.append( + { + "id": activity.id, + "type": activity.entity_type or "default", + "action": activity.action or "unknown", + "description": activity.description or "Activity", + "created_at": activity.created_at.isoformat() if activity.created_at else None, + } + ) + + return jsonify({"success": True, "activities": activities_data}) + + +@api_bp.route("/api/activities/stats") @login_required def get_activity_stats(): """Get activity statistics""" from app.models import Activity from sqlalchemy import func - + # Get date range (default to last 7 days) - days = request.args.get('days', 7, type=int) + days = request.args.get("days", 7, type=int) since = datetime.utcnow() - timedelta(days=days) - + # Build base query query = Activity.query.filter(Activity.created_at >= since) - + # Filter by user if not admin if not current_user.is_admin: query = query.filter_by(user_id=current_user.id) - + # Get counts by entity type - entity_counts = db.session.query( - Activity.entity_type, - func.count(Activity.id).label('count') - ).filter(Activity.created_at >= since) - + entity_counts = db.session.query(Activity.entity_type, func.count(Activity.id).label("count")).filter( + Activity.created_at >= since + ) + if not current_user.is_admin: entity_counts = entity_counts.filter_by(user_id=current_user.id) - + entity_counts = entity_counts.group_by(Activity.entity_type).all() - + # Get counts by action - action_counts = db.session.query( - Activity.action, - func.count(Activity.id).label('count') - ).filter(Activity.created_at >= since) - + action_counts = db.session.query(Activity.action, func.count(Activity.id).label("count")).filter( + Activity.created_at >= since + ) + if not current_user.is_admin: action_counts = action_counts.filter_by(user_id=current_user.id) - + action_counts = action_counts.group_by(Activity.action).all() - + # Get most active users (admins only) user_activity = [] if current_user.is_admin: - user_activity = db.session.query( - User.username, - User.display_name, - func.count(Activity.id).label('count') - ).join( - Activity, User.id == Activity.user_id - ).filter( - Activity.created_at >= since - ).group_by( - User.id, User.username, User.display_name - ).order_by( - func.count(Activity.id).desc() - ).limit(10).all() - - return jsonify({ - 'total_activities': query.count(), - 'entity_counts': {entity: count for entity, count in entity_counts}, - 'action_counts': {action: count for action, count in action_counts}, - 'user_activity': [ - {'username': u[0], 'display_name': u[1], 'count': u[2]} - for u in user_activity - ], - 'period_days': days - }) + user_activity = ( + db.session.query(User.username, User.display_name, func.count(Activity.id).label("count")) + .join(Activity, User.id == Activity.user_id) + .filter(Activity.created_at >= since) + .group_by(User.id, User.username, User.display_name) + .order_by(func.count(Activity.id).desc()) + .limit(10) + .all() + ) + + return jsonify( + { + "total_activities": query.count(), + "entity_counts": {entity: count for entity, count in entity_counts}, + "action_counts": {action: count for action, count in action_counts}, + "user_activity": [{"username": u[0], "display_name": u[1], "count": u[2]} for u in user_activity], + "period_days": days, + } + ) + # WebSocket event handlers -@socketio.on('connect') +@socketio.on("connect") def handle_connect(): """Handle WebSocket connection""" - print(f'Client connected: {request.sid}') + print(f"Client connected: {request.sid}") -@socketio.on('disconnect') + +@socketio.on("disconnect") def handle_disconnect(): """Handle WebSocket disconnection""" - print(f'Client disconnected: {request.sid}') + print(f"Client disconnected: {request.sid}") + -@socketio.on('join_user_room') +@socketio.on("join_user_room") def handle_join_user_room(data): """Join user-specific room for real-time updates""" - user_id = data.get('user_id') + user_id = data.get("user_id") if user_id and current_user.is_authenticated and current_user.id == user_id: - socketio.join_room(f'user_{user_id}') - print(f'User {user_id} joined room') + socketio.join_room(f"user_{user_id}") + print(f"User {user_id} joined room") + -@socketio.on('leave_user_room') +@socketio.on("leave_user_room") def handle_leave_user_room(data): """Leave user-specific room""" - user_id = data.get('user_id') + user_id = data.get("user_id") if user_id: - socketio.leave_room(f'user_{user_id}') - print(f'User {user_id} left room') + socketio.leave_room(f"user_{user_id}") + print(f"User {user_id} left room") diff --git a/app/routes/api/__init__.py b/app/routes/api/__init__.py index 9b2da522..9e87cb74 100644 --- a/app/routes/api/__init__.py +++ b/app/routes/api/__init__.py @@ -18,7 +18,7 @@ # Import legacy api_bp from the api.py module file # We need to load it directly since Python prioritizes packages over modules -api_module_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'api.py') +api_module_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "api.py") try: spec = importlib.util.spec_from_file_location("app.routes.api_legacy", api_module_path) @@ -31,10 +31,11 @@ except Exception as e: # Last resort: create a dummy blueprint to prevent import errors from flask import Blueprint - api_bp = Blueprint('api', __name__) + + api_bp = Blueprint("api", __name__) import logging + logger = logging.getLogger(__name__) logger.warning(f"Could not import api_bp from api.py: {e}. Using dummy blueprint.") -__all__ = ['api_v1_bp', 'api_bp'] - +__all__ = ["api_v1_bp", "api_bp"] diff --git a/app/routes/api/v1/__init__.py b/app/routes/api/v1/__init__.py index 746016a3..8f441710 100644 --- a/app/routes/api/v1/__init__.py +++ b/app/routes/api/v1/__init__.py @@ -14,11 +14,10 @@ from flask import Blueprint # Create v1 blueprint -api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1') +api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") # Import all v1 endpoints # Note: The actual endpoints are in api_v1.py for now # This structure allows for future reorganization -__all__ = ['api_v1_bp'] - +__all__ = ["api_v1_bp"] diff --git a/app/routes/api_docs.py b/app/routes/api_docs.py index ff377b0f..fa655473 100644 --- a/app/routes/api_docs.py +++ b/app/routes/api_docs.py @@ -1,31 +1,32 @@ """API Documentation with Swagger UI""" + from flask import Blueprint, jsonify, render_template_string from flask_swagger_ui import get_swaggerui_blueprint # Create blueprint for serving OpenAPI spec -api_docs_bp = Blueprint('api_docs', __name__) +api_docs_bp = Blueprint("api_docs", __name__) -SWAGGER_URL = '/api/docs' -API_URL = '/api/openapi.json' +SWAGGER_URL = "/api/docs" +API_URL = "/api/openapi.json" # Create Swagger UI blueprint swaggerui_blueprint = get_swaggerui_blueprint( SWAGGER_URL, API_URL, config={ - 'app_name': "TimeTracker REST API", - 'defaultModelsExpandDepth': -1, - 'displayRequestDuration': True, - 'docExpansion': 'list', - 'filter': True, - 'showExtensions': True, - 'showCommonExtensions': True, - 'syntaxHighlight.theme': 'monokai' - } + "app_name": "TimeTracker REST API", + "defaultModelsExpandDepth": -1, + "displayRequestDuration": True, + "docExpansion": "list", + "filter": True, + "showExtensions": True, + "showCommonExtensions": True, + "syntaxHighlight.theme": "monokai", + }, ) -@api_docs_bp.route('/api/openapi.json') +@api_docs_bp.route("/api/openapi.json") def openapi_spec(): """Serve the OpenAPI specification""" spec = { @@ -138,33 +139,24 @@ def openapi_spec(): Example: `2024-01-15T14:30:00Z` """, - "contact": { - "name": "TimeTracker API Support" - }, - "license": { - "name": "MIT" - } + "contact": {"name": "TimeTracker API Support"}, + "license": {"name": "MIT"}, }, - "servers": [ - { - "url": "/api/v1", - "description": "API v1" - } - ], + "servers": [{"url": "/api/v1", "description": "API v1"}], "components": { "securitySchemes": { "BearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "API Token", - "description": "Enter your API token (format: tt_xxxxx...)" + "description": "Enter your API token (format: tt_xxxxx...)", }, "ApiKeyAuth": { "type": "apiKey", "in": "header", "name": "X-API-Key", - "description": "API token in X-API-Key header" - } + "description": "API token in X-API-Key header", + }, }, "schemas": { "Project": { @@ -177,8 +169,8 @@ def openapi_spec(): "hourly_rate": {"type": "number"}, "estimated_hours": {"type": "number", "nullable": True}, "status": {"type": "string", "enum": ["active", "archived", "on_hold"]}, - "created_at": {"type": "string", "format": "date-time"} - } + "created_at": {"type": "string", "format": "date-time"}, + }, }, "TimeEntry": { "type": "object", @@ -193,8 +185,8 @@ def openapi_spec(): "notes": {"type": "string", "nullable": True}, "tags": {"type": "string", "nullable": True}, "billable": {"type": "boolean"}, - "source": {"type": "string"} - } + "source": {"type": "string"}, + }, }, "Task": { "type": "object", @@ -204,8 +196,8 @@ def openapi_spec(): "description": {"type": "string", "nullable": True}, "project_id": {"type": "integer"}, "status": {"type": "string", "enum": ["todo", "in_progress", "review", "done", "cancelled"]}, - "priority": {"type": "integer"} - } + "priority": {"type": "integer"}, + }, }, "Client": { "type": "object", @@ -214,16 +206,10 @@ def openapi_spec(): "name": {"type": "string"}, "email": {"type": "string", "nullable": True}, "company": {"type": "string", "nullable": True}, - "phone": {"type": "string", "nullable": True} - } - }, - "Error": { - "type": "object", - "properties": { - "error": {"type": "string"}, - "message": {"type": "string"} - } + "phone": {"type": "string", "nullable": True}, + }, }, + "Error": {"type": "object", "properties": {"error": {"type": "string"}, "message": {"type": "string"}}}, "Pagination": { "type": "object", "properties": { @@ -234,48 +220,21 @@ def openapi_spec(): "has_next": {"type": "boolean"}, "has_prev": {"type": "boolean"}, "next_page": {"type": "integer", "nullable": True}, - "prev_page": {"type": "integer", "nullable": True} - } - } - } + "prev_page": {"type": "integer", "nullable": True}, + }, + }, + }, }, - "security": [ - {"BearerAuth": []}, - {"ApiKeyAuth": []} - ], + "security": [{"BearerAuth": []}, {"ApiKeyAuth": []}], "tags": [ - { - "name": "System", - "description": "System information and health checks" - }, - { - "name": "Projects", - "description": "Project management operations" - }, - { - "name": "Time Entries", - "description": "Time tracking operations" - }, - { - "name": "Timer", - "description": "Timer control operations" - }, - { - "name": "Tasks", - "description": "Task management operations" - }, - { - "name": "Clients", - "description": "Client management operations" - }, - { - "name": "Reports", - "description": "Reporting and analytics" - }, - { - "name": "Users", - "description": "User management operations" - } + {"name": "System", "description": "System information and health checks"}, + {"name": "Projects", "description": "Project management operations"}, + {"name": "Time Entries", "description": "Time tracking operations"}, + {"name": "Timer", "description": "Timer control operations"}, + {"name": "Tasks", "description": "Task management operations"}, + {"name": "Clients", "description": "Client management operations"}, + {"name": "Reports", "description": "Reporting and analytics"}, + {"name": "Users", "description": "User management operations"}, ], "paths": { "/info": { @@ -295,13 +254,13 @@ def openapi_spec(): "api_version": {"type": "string"}, "app_version": {"type": "string"}, "documentation_url": {"type": "string"}, - "endpoints": {"type": "object"} - } + "endpoints": {"type": "object"}, + }, } } - } + }, } - } + }, } }, "/health": { @@ -310,11 +269,7 @@ def openapi_spec(): "summary": "Health check", "description": "Check if the API is healthy and operational", "security": [], - "responses": { - "200": { - "description": "API is healthy" - } - } + "responses": {"200": {"description": "API is healthy"}}, } }, "/projects": { @@ -326,32 +281,17 @@ def openapi_spec(): { "name": "status", "in": "query", - "schema": {"type": "string", "enum": ["active", "archived", "on_hold"]} - }, - { - "name": "client_id", - "in": "query", - "schema": {"type": "integer"} - }, - { - "name": "page", - "in": "query", - "schema": {"type": "integer", "default": 1} + "schema": {"type": "string", "enum": ["active", "archived", "on_hold"]}, }, + {"name": "client_id", "in": "query", "schema": {"type": "integer"}}, + {"name": "page", "in": "query", "schema": {"type": "integer", "default": 1}}, { "name": "per_page", "in": "query", - "schema": {"type": "integer", "default": 50, "maximum": 100} - } - ], - "responses": { - "200": { - "description": "List of projects" + "schema": {"type": "integer", "default": 50, "maximum": 100}, }, - "401": { - "description": "Unauthorized" - } - } + ], + "responses": {"200": {"description": "List of projects"}, "401": {"description": "Unauthorized"}}, }, "post": { "tags": ["Projects"], @@ -370,21 +310,18 @@ def openapi_spec(): "client_id": {"type": "integer"}, "hourly_rate": {"type": "number"}, "estimated_hours": {"type": "number"}, - "status": {"type": "string", "enum": ["active", "archived", "on_hold"], "default": "active"} - } + "status": { + "type": "string", + "enum": ["active", "archived", "on_hold"], + "default": "active", + }, + }, } } - } - }, - "responses": { - "201": { - "description": "Project created" }, - "400": { - "description": "Invalid input" - } - } - } + }, + "responses": {"201": {"description": "Project created"}, "400": {"description": "Invalid input"}}, + }, }, "/projects/{project_id}": { "get": { @@ -392,74 +329,41 @@ def openapi_spec(): "summary": "Get project", "description": "Get details of a specific project", "parameters": [ - { - "name": "project_id", - "in": "path", - "required": True, - "schema": {"type": "integer"} - } + {"name": "project_id", "in": "path", "required": True, "schema": {"type": "integer"}} ], "responses": { - "200": { - "description": "Project details" - }, - "404": { - "description": "Project not found" - } - } + "200": {"description": "Project details"}, + "404": {"description": "Project not found"}, + }, }, "put": { "tags": ["Projects"], "summary": "Update project", "description": "Update an existing project", "parameters": [ - { - "name": "project_id", - "in": "path", - "required": True, - "schema": {"type": "integer"} - } + {"name": "project_id", "in": "path", "required": True, "schema": {"type": "integer"}} ], "requestBody": { "required": True, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Project" - } - } - } + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Project"}}}, }, "responses": { - "200": { - "description": "Project updated" - }, - "404": { - "description": "Project not found" - } - } + "200": {"description": "Project updated"}, + "404": {"description": "Project not found"}, + }, }, "delete": { "tags": ["Projects"], "summary": "Archive project", "description": "Archive a project (soft delete)", "parameters": [ - { - "name": "project_id", - "in": "path", - "required": True, - "schema": {"type": "integer"} - } + {"name": "project_id", "in": "path", "required": True, "schema": {"type": "integer"}} ], "responses": { - "200": { - "description": "Project archived" - }, - "404": { - "description": "Project not found" - } - } - } + "200": {"description": "Project archived"}, + "404": {"description": "Project not found"}, + }, + }, }, "/time-entries": { "get": { @@ -473,11 +377,9 @@ def openapi_spec(): {"name": "end_date", "in": "query", "schema": {"type": "string", "format": "date"}}, {"name": "billable", "in": "query", "schema": {"type": "boolean"}}, {"name": "page", "in": "query", "schema": {"type": "integer"}}, - {"name": "per_page", "in": "query", "schema": {"type": "integer"}} + {"name": "per_page", "in": "query", "schema": {"type": "integer"}}, ], - "responses": { - "200": {"description": "List of time entries"} - } + "responses": {"200": {"description": "List of time entries"}}, }, "post": { "tags": ["Time Entries"], @@ -497,25 +399,21 @@ def openapi_spec(): "end_time": {"type": "string", "format": "date-time"}, "notes": {"type": "string"}, "tags": {"type": "string"}, - "billable": {"type": "boolean", "default": True} - } + "billable": {"type": "boolean", "default": True}, + }, } } - } + }, }, - "responses": { - "201": {"description": "Time entry created"} - } - } + "responses": {"201": {"description": "Time entry created"}}, + }, }, "/timer/status": { "get": { "tags": ["Timer"], "summary": "Get timer status", "description": "Get the current timer status for the authenticated user", - "responses": { - "200": {"description": "Timer status"} - } + "responses": {"200": {"description": "Timer status"}}, } }, "/timer/start": { @@ -530,17 +428,12 @@ def openapi_spec(): "schema": { "type": "object", "required": ["project_id"], - "properties": { - "project_id": {"type": "integer"}, - "task_id": {"type": "integer"} - } + "properties": {"project_id": {"type": "integer"}, "task_id": {"type": "integer"}}, } } - } + }, }, - "responses": { - "201": {"description": "Timer started"} - } + "responses": {"201": {"description": "Timer started"}}, } }, "/timer/stop": { @@ -548,9 +441,7 @@ def openapi_spec(): "tags": ["Timer"], "summary": "Stop timer", "description": "Stop the active timer for the authenticated user", - "responses": { - "200": {"description": "Timer stopped"} - } + "responses": {"200": {"description": "Timer stopped"}}, } }, "/users/me": { @@ -558,13 +449,10 @@ def openapi_spec(): "tags": ["Users"], "summary": "Get current user", "description": "Get information about the authenticated user", - "responses": { - "200": {"description": "User information"} - } + "responses": {"200": {"description": "User information"}}, } - } - } + }, + }, } - - return jsonify(spec) + return jsonify(spec) diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index bdcb6d7d..9da3fab8 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -1,4 +1,5 @@ """REST API v1 - Comprehensive API endpoints with token authentication""" + from flask import Blueprint, jsonify, request, current_app, g from app import db from app.models import ( @@ -49,31 +50,32 @@ from app.utils.timezone import parse_local_datetime, utc_to_local from app.models.time_entry import local_now -api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1') +api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1") # ==================== Helper Functions ==================== + def paginate_query(query, page=None, per_page=None): """Paginate a SQLAlchemy query""" - page = page or int(request.args.get('page', 1)) - per_page = per_page or int(request.args.get('per_page', 50)) + page = page or int(request.args.get("page", 1)) + per_page = per_page or int(request.args.get("per_page", 50)) per_page = min(per_page, 100) # Max 100 items per page - + paginated = query.paginate(page=page, per_page=per_page, error_out=False) - + return { - 'items': paginated.items, - 'pagination': { - 'page': paginated.page, - 'per_page': paginated.per_page, - 'total': paginated.total, - 'pages': paginated.pages, - 'has_next': paginated.has_next, - 'has_prev': paginated.has_prev, - 'next_page': paginated.page + 1 if paginated.has_next else None, - 'prev_page': paginated.page - 1 if paginated.has_prev else None - } + "items": paginated.items, + "pagination": { + "page": paginated.page, + "per_page": paginated.per_page, + "total": paginated.total, + "pages": paginated.pages, + "has_next": paginated.has_next, + "has_prev": paginated.has_prev, + "next_page": paginated.page + 1 if paginated.has_next else None, + "prev_page": paginated.page - 1 if paginated.has_prev else None, + }, } @@ -84,8 +86,8 @@ def parse_datetime(dt_str): try: # Handle ISO format with timezone ts = dt_str.strip() - if ts.endswith('Z'): - ts = ts[:-1] + '+00:00' + if ts.endswith("Z"): + ts = ts[:-1] + "+00:00" dt = datetime.fromisoformat(ts) # Convert to local naive for storage if dt.tzinfo is not None: @@ -101,6 +103,7 @@ def _parse_date(dstr): return None try: from datetime import date as _date + return _date.fromisoformat(str(dstr)) except Exception: return None @@ -108,7 +111,8 @@ def _parse_date(dstr): # ==================== API Info & Health ==================== -@api_v1_bp.route('/info', methods=['GET']) + +@api_v1_bp.route("/info", methods=["GET"]) def api_info(): """Get API information and version --- @@ -129,48 +133,50 @@ def api_info(): documentation_url: type: string """ - return jsonify({ - 'api_version': 'v1', - 'app_version': current_app.config.get('APP_VERSION', '1.0.0'), - 'documentation_url': '/api/docs', - 'authentication': 'API Token (Bearer or X-API-Key header)', - 'endpoints': { - 'projects': '/api/v1/projects', - 'time_entries': '/api/v1/time-entries', - 'tasks': '/api/v1/tasks', - 'clients': '/api/v1/clients', - 'invoices': '/api/v1/invoices', - 'expenses': '/api/v1/expenses', - 'payments': '/api/v1/payments', - 'mileage': '/api/v1/mileage', - 'per_diems': '/api/v1/per-diems', - 'per_diem_rates': '/api/v1/per-diem-rates', - 'budget_alerts': '/api/v1/budget-alerts', - 'calendar_events': '/api/v1/calendar/events', - 'kanban_columns': '/api/v1/kanban/columns', - 'saved_filters': '/api/v1/saved-filters', - 'time_entry_templates': '/api/v1/time-entry-templates', - 'comments': '/api/v1/comments', - 'recurring_invoices': '/api/v1/recurring-invoices', - 'credit_notes': '/api/v1/credit-notes', - 'client_notes': '/api/v1/clients//notes', - 'project_costs': '/api/v1/projects//costs', - 'tax_rules': '/api/v1/tax-rules', - 'currencies': '/api/v1/currencies', - 'exchange_rates': '/api/v1/exchange-rates', - 'favorites': '/api/v1/users/me/favorites/projects', - 'activities': '/api/v1/activities', - 'audit_logs': '/api/v1/audit-logs', - 'invoice_pdf_templates': '/api/v1/invoice-pdf-templates', - 'invoice_templates': '/api/v1/invoice-templates', - 'webhooks': '/api/v1/webhooks', - 'users': '/api/v1/users', - 'reports': '/api/v1/reports' + return jsonify( + { + "api_version": "v1", + "app_version": current_app.config.get("APP_VERSION", "1.0.0"), + "documentation_url": "/api/docs", + "authentication": "API Token (Bearer or X-API-Key header)", + "endpoints": { + "projects": "/api/v1/projects", + "time_entries": "/api/v1/time-entries", + "tasks": "/api/v1/tasks", + "clients": "/api/v1/clients", + "invoices": "/api/v1/invoices", + "expenses": "/api/v1/expenses", + "payments": "/api/v1/payments", + "mileage": "/api/v1/mileage", + "per_diems": "/api/v1/per-diems", + "per_diem_rates": "/api/v1/per-diem-rates", + "budget_alerts": "/api/v1/budget-alerts", + "calendar_events": "/api/v1/calendar/events", + "kanban_columns": "/api/v1/kanban/columns", + "saved_filters": "/api/v1/saved-filters", + "time_entry_templates": "/api/v1/time-entry-templates", + "comments": "/api/v1/comments", + "recurring_invoices": "/api/v1/recurring-invoices", + "credit_notes": "/api/v1/credit-notes", + "client_notes": "/api/v1/clients//notes", + "project_costs": "/api/v1/projects//costs", + "tax_rules": "/api/v1/tax-rules", + "currencies": "/api/v1/currencies", + "exchange_rates": "/api/v1/exchange-rates", + "favorites": "/api/v1/users/me/favorites/projects", + "activities": "/api/v1/activities", + "audit_logs": "/api/v1/audit-logs", + "invoice_pdf_templates": "/api/v1/invoice-pdf-templates", + "invoice_templates": "/api/v1/invoice-templates", + "webhooks": "/api/v1/webhooks", + "users": "/api/v1/users", + "reports": "/api/v1/reports", + }, } - }) + ) -@api_v1_bp.route('/health', methods=['GET']) +@api_v1_bp.route("/health", methods=["GET"]) def health_check(): """API health check endpoint --- @@ -180,13 +186,14 @@ def health_check(): 200: description: API is healthy """ - return jsonify({'status': 'healthy', 'timestamp': local_now().isoformat()}) + return jsonify({"status": "healthy", "timestamp": local_now().isoformat()}) # ==================== Projects ==================== -@api_v1_bp.route('/projects', methods=['GET']) -@require_api_token('read:projects') + +@api_v1_bp.route("/projects", methods=["GET"]) +@require_api_token("read:projects") def list_projects(): """List all projects --- @@ -213,31 +220,28 @@ def list_projects(): description: List of projects """ query = Project.query - + # Filter by status - status = request.args.get('status') + status = request.args.get("status") if status: query = query.filter_by(status=status) - + # Filter by client - client_id = request.args.get('client_id', type=int) + client_id = request.args.get("client_id", type=int) if client_id: query = query.filter_by(client_id=client_id) - + # Order by name query = query.order_by(Project.name) - + # Paginate result = paginate_query(query) - - return jsonify({ - 'projects': [p.to_dict() for p in result['items']], - 'pagination': result['pagination'] - }) + return jsonify({"projects": [p.to_dict() for p in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/projects/', methods=['GET']) -@require_api_token('read:projects') + +@api_v1_bp.route("/projects/", methods=["GET"]) +@require_api_token("read:projects") def get_project(project_id): """Get a specific project --- @@ -257,11 +261,11 @@ def get_project(project_id): description: Project not found """ project = Project.query.get_or_404(project_id) - return jsonify({'project': project.to_dict()}) + return jsonify({"project": project.to_dict()}) -@api_v1_bp.route('/projects', methods=['POST']) -@require_api_token('write:projects') +@api_v1_bp.route("/projects", methods=["POST"]) +@require_api_token("write:projects") def create_project(): """Create a new project --- @@ -298,32 +302,29 @@ def create_project(): description: Invalid input """ data = request.get_json() or {} - + # Validate required fields - if not data.get('name'): - return jsonify({'error': 'Project name is required'}), 400 - + if not data.get("name"): + return jsonify({"error": "Project name is required"}), 400 + # Create project project = Project( - name=data['name'], - description=data.get('description', ''), - client_id=data.get('client_id'), - hourly_rate=data.get('hourly_rate', 0.0), - estimated_hours=data.get('estimated_hours'), - status=data.get('status', 'active') + name=data["name"], + description=data.get("description", ""), + client_id=data.get("client_id"), + hourly_rate=data.get("hourly_rate", 0.0), + estimated_hours=data.get("estimated_hours"), + status=data.get("status", "active"), ) - + db.session.add(project) db.session.commit() - - return jsonify({ - 'message': 'Project created successfully', - 'project': project.to_dict() - }), 201 + + return jsonify({"message": "Project created successfully", "project": project.to_dict()}), 201 -@api_v1_bp.route('/projects/', methods=['PUT', 'PATCH']) -@require_api_token('write:projects') +@api_v1_bp.route("/projects/", methods=["PUT", "PATCH"]) +@require_api_token("write:projects") def update_project(project_id): """Update a project --- @@ -348,31 +349,28 @@ def update_project(project_id): """ project = Project.query.get_or_404(project_id) data = request.get_json() or {} - + # Update fields - if 'name' in data: - project.name = data['name'] - if 'description' in data: - project.description = data['description'] - if 'client_id' in data: - project.client_id = data['client_id'] - if 'hourly_rate' in data: - project.hourly_rate = data['hourly_rate'] - if 'estimated_hours' in data: - project.estimated_hours = data['estimated_hours'] - if 'status' in data: - project.status = data['status'] - + if "name" in data: + project.name = data["name"] + if "description" in data: + project.description = data["description"] + if "client_id" in data: + project.client_id = data["client_id"] + if "hourly_rate" in data: + project.hourly_rate = data["hourly_rate"] + if "estimated_hours" in data: + project.estimated_hours = data["estimated_hours"] + if "status" in data: + project.status = data["status"] + db.session.commit() - - return jsonify({ - 'message': 'Project updated successfully', - 'project': project.to_dict() - }) + return jsonify({"message": "Project updated successfully", "project": project.to_dict()}) -@api_v1_bp.route('/projects/', methods=['DELETE']) -@require_api_token('write:projects') + +@api_v1_bp.route("/projects/", methods=["DELETE"]) +@require_api_token("write:projects") def delete_project(project_id): """Delete/archive a project --- @@ -392,18 +390,19 @@ def delete_project(project_id): description: Project not found """ project = Project.query.get_or_404(project_id) - + # Archive instead of deleting - project.status = 'archived' + project.status = "archived" db.session.commit() - - return jsonify({'message': 'Project archived successfully'}) + + return jsonify({"message": "Project archived successfully"}) # ==================== Time Entries ==================== -@api_v1_bp.route('/time-entries', methods=['GET']) -@require_api_token('read:time_entries') + +@api_v1_bp.route("/time-entries", methods=["GET"]) +@require_api_token("read:time_entries") def list_time_entries(): """List time entries --- @@ -440,27 +439,27 @@ def list_time_entries(): description: List of time entries """ query = TimeEntry.query - + # Filter by project - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter_by(project_id=project_id) - + # Filter by user (non-admin can only see their own) - user_id = request.args.get('user_id', type=int) + user_id = request.args.get("user_id", type=int) if user_id: if g.api_user.is_admin or user_id == g.api_user.id: query = query.filter_by(user_id=user_id) else: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 else: # Default to current user's entries if not admin if not g.api_user.is_admin: query = query.filter_by(user_id=g.api_user.id) - + # Filter by date range - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") if start_date: start_dt = parse_datetime(start_date) if start_dt: @@ -469,30 +468,27 @@ def list_time_entries(): end_dt = parse_datetime(end_date) if end_dt: query = query.filter(TimeEntry.start_time <= end_dt) - + # Filter by billable - billable = request.args.get('billable') + billable = request.args.get("billable") if billable is not None: - query = query.filter_by(billable=billable.lower() == 'true') - + query = query.filter_by(billable=billable.lower() == "true") + # Only completed entries by default - if request.args.get('include_active') != 'true': + if request.args.get("include_active") != "true": query = query.filter(TimeEntry.end_time.isnot(None)) - + # Order by start time desc query = query.order_by(TimeEntry.start_time.desc()) - + # Paginate result = paginate_query(query) - - return jsonify({ - 'time_entries': [e.to_dict() for e in result['items']], - 'pagination': result['pagination'] - }) + + return jsonify({"time_entries": [e.to_dict() for e in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/time-entries/', methods=['GET']) -@require_api_token('read:time_entries') +@api_v1_bp.route("/time-entries/", methods=["GET"]) +@require_api_token("read:time_entries") def get_time_entry(entry_id): """Get a specific time entry --- @@ -512,16 +508,16 @@ def get_time_entry(entry_id): description: Time entry not found """ entry = TimeEntry.query.get_or_404(entry_id) - + # Check permissions if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - - return jsonify({'time_entry': entry.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"time_entry": entry.to_dict()}) -@api_v1_bp.route('/time-entries', methods=['POST']) -@require_api_token('write:time_entries') + +@api_v1_bp.route("/time-entries", methods=["POST"]) +@require_api_token("write:time_entries") def create_time_entry(): """Create a new time entry --- @@ -562,53 +558,50 @@ def create_time_entry(): description: Invalid input """ data = request.get_json() or {} - + # Validate required fields - if not data.get('project_id'): - return jsonify({'error': 'project_id is required'}), 400 - if not data.get('start_time'): - return jsonify({'error': 'start_time is required'}), 400 - + if not data.get("project_id"): + return jsonify({"error": "project_id is required"}), 400 + if not data.get("start_time"): + return jsonify({"error": "start_time is required"}), 400 + # Validate project - project = Project.query.filter_by(id=data['project_id'], status='active').first() + project = Project.query.filter_by(id=data["project_id"], status="active").first() if not project: - return jsonify({'error': 'Invalid project'}), 400 - + return jsonify({"error": "Invalid project"}), 400 + # Parse times - start_time = parse_datetime(data['start_time']) + start_time = parse_datetime(data["start_time"]) if not start_time: - return jsonify({'error': 'Invalid start_time format'}), 400 - + return jsonify({"error": "Invalid start_time format"}), 400 + end_time = None - if data.get('end_time'): - end_time = parse_datetime(data['end_time']) + if data.get("end_time"): + end_time = parse_datetime(data["end_time"]) if end_time and end_time <= start_time: - return jsonify({'error': 'end_time must be after start_time'}), 400 - + return jsonify({"error": "end_time must be after start_time"}), 400 + # Create entry entry = TimeEntry( user_id=g.api_user.id, - project_id=data['project_id'], - task_id=data.get('task_id'), + project_id=data["project_id"], + task_id=data.get("task_id"), start_time=start_time, end_time=end_time, - notes=data.get('notes'), - tags=data.get('tags'), - billable=data.get('billable', True), - source='api' + notes=data.get("notes"), + tags=data.get("tags"), + billable=data.get("billable", True), + source="api", ) - + db.session.add(entry) db.session.commit() - - return jsonify({ - 'message': 'Time entry created successfully', - 'time_entry': entry.to_dict() - }), 201 + return jsonify({"message": "Time entry created successfully", "time_entry": entry.to_dict()}), 201 -@api_v1_bp.route('/time-entries/', methods=['PUT', 'PATCH']) -@require_api_token('write:time_entries') + +@api_v1_bp.route("/time-entries/", methods=["PUT", "PATCH"]) +@require_api_token("write:time_entries") def update_time_entry(entry_id): """Update a time entry --- @@ -632,47 +625,44 @@ def update_time_entry(entry_id): description: Time entry not found """ entry = TimeEntry.query.get_or_404(entry_id) - + # Check permissions if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + data = request.get_json() or {} - + # Update fields - if 'project_id' in data: - entry.project_id = data['project_id'] - if 'task_id' in data: - entry.task_id = data['task_id'] - if 'start_time' in data: - start_time = parse_datetime(data['start_time']) + if "project_id" in data: + entry.project_id = data["project_id"] + if "task_id" in data: + entry.task_id = data["task_id"] + if "start_time" in data: + start_time = parse_datetime(data["start_time"]) if start_time: entry.start_time = start_time - if 'end_time' in data: - if data['end_time'] is None: + if "end_time" in data: + if data["end_time"] is None: entry.end_time = None else: - end_time = parse_datetime(data['end_time']) + end_time = parse_datetime(data["end_time"]) if end_time: entry.end_time = end_time - if 'notes' in data: - entry.notes = data['notes'] - if 'tags' in data: - entry.tags = data['tags'] - if 'billable' in data: - entry.billable = data['billable'] - + if "notes" in data: + entry.notes = data["notes"] + if "tags" in data: + entry.tags = data["tags"] + if "billable" in data: + entry.billable = data["billable"] + entry.updated_at = local_now() db.session.commit() - - return jsonify({ - 'message': 'Time entry updated successfully', - 'time_entry': entry.to_dict() - }) + return jsonify({"message": "Time entry updated successfully", "time_entry": entry.to_dict()}) -@api_v1_bp.route('/time-entries/', methods=['DELETE']) -@require_api_token('write:time_entries') + +@api_v1_bp.route("/time-entries/", methods=["DELETE"]) +@require_api_token("write:time_entries") def delete_time_entry(entry_id): """Delete a time entry --- @@ -692,25 +682,26 @@ def delete_time_entry(entry_id): description: Time entry not found """ entry = TimeEntry.query.get_or_404(entry_id) - + # Check permissions if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + # Don't allow deletion of active entries if entry.is_active: - return jsonify({'error': 'Cannot delete active time entry'}), 400 - + return jsonify({"error": "Cannot delete active time entry"}), 400 + db.session.delete(entry) db.session.commit() - - return jsonify({'message': 'Time entry deleted successfully'}) + + return jsonify({"message": "Time entry deleted successfully"}) # ==================== Timer Control ==================== -@api_v1_bp.route('/timer/status', methods=['GET']) -@require_api_token('read:time_entries') + +@api_v1_bp.route("/timer/status", methods=["GET"]) +@require_api_token("read:time_entries") def timer_status(): """Get current timer status for authenticated user --- @@ -723,21 +714,15 @@ def timer_status(): description: Timer status """ active_timer = g.api_user.active_timer - + if not active_timer: - return jsonify({ - 'active': False, - 'timer': None - }) - - return jsonify({ - 'active': True, - 'timer': active_timer.to_dict() - }) - - -@api_v1_bp.route('/timer/start', methods=['POST']) -@require_api_token('write:time_entries') + return jsonify({"active": False, "timer": None}) + + return jsonify({"active": True, "timer": active_timer.to_dict()}) + + +@api_v1_bp.route("/timer/start", methods=["POST"]) +@require_api_token("write:time_entries") def start_timer(): """Start a new timer --- @@ -765,40 +750,33 @@ def start_timer(): description: Invalid input or timer already running """ data = request.get_json() or {} - + # Check if timer already running if g.api_user.active_timer: - return jsonify({'error': 'Timer already running'}), 400 - + return jsonify({"error": "Timer already running"}), 400 + # Validate project - project_id = data.get('project_id') + project_id = data.get("project_id") if not project_id: - return jsonify({'error': 'project_id is required'}), 400 - - project = Project.query.filter_by(id=project_id, status='active').first() + return jsonify({"error": "project_id is required"}), 400 + + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - return jsonify({'error': 'Invalid project'}), 400 - + return jsonify({"error": "Invalid project"}), 400 + # Create timer timer = TimeEntry( - user_id=g.api_user.id, - project_id=project_id, - task_id=data.get('task_id'), - start_time=local_now(), - source='api' + user_id=g.api_user.id, project_id=project_id, task_id=data.get("task_id"), start_time=local_now(), source="api" ) - + db.session.add(timer) db.session.commit() - - return jsonify({ - 'message': 'Timer started successfully', - 'timer': timer.to_dict() - }), 201 + + return jsonify({"message": "Timer started successfully", "timer": timer.to_dict()}), 201 -@api_v1_bp.route('/timer/stop', methods=['POST']) -@require_api_token('write:time_entries') +@api_v1_bp.route("/timer/stop", methods=["POST"]) +@require_api_token("write:time_entries") def stop_timer(): """Stop the active timer --- @@ -813,22 +791,20 @@ def stop_timer(): description: No active timer """ active_timer = g.api_user.active_timer - + if not active_timer: - return jsonify({'error': 'No active timer'}), 400 - + return jsonify({"error": "No active timer"}), 400 + active_timer.stop_timer() - - return jsonify({ - 'message': 'Timer stopped successfully', - 'time_entry': active_timer.to_dict() - }) + + return jsonify({"message": "Timer stopped successfully", "time_entry": active_timer.to_dict()}) # ==================== Tasks ==================== -@api_v1_bp.route('/tasks', methods=['GET']) -@require_api_token('read:tasks') + +@api_v1_bp.route("/tasks", methods=["GET"]) +@require_api_token("read:tasks") def list_tasks(): """List tasks --- @@ -854,31 +830,28 @@ def list_tasks(): description: List of tasks """ query = Task.query - + # Filter by project - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter_by(project_id=project_id) - + # Filter by status - status = request.args.get('status') + status = request.args.get("status") if status: query = query.filter_by(status=status) - + # Order by priority and name query = query.order_by(Task.priority.desc(), Task.name) - + # Paginate result = paginate_query(query) - - return jsonify({ - 'tasks': [t.to_dict() for t in result['items']], - 'pagination': result['pagination'] - }) + + return jsonify({"tasks": [t.to_dict() for t in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/tasks/', methods=['GET']) -@require_api_token('read:tasks') +@api_v1_bp.route("/tasks/", methods=["GET"]) +@require_api_token("read:tasks") def get_task(task_id): """Get a specific task --- @@ -898,11 +871,11 @@ def get_task(task_id): description: Task not found """ task = Task.query.get_or_404(task_id) - return jsonify({'task': task.to_dict()}) + return jsonify({"task": task.to_dict()}) -@api_v1_bp.route('/tasks', methods=['POST']) -@require_api_token('write:tasks') +@api_v1_bp.route("/tasks", methods=["POST"]) +@require_api_token("write:tasks") def create_task(): """Create a new task --- @@ -937,33 +910,30 @@ def create_task(): description: Invalid input """ data = request.get_json() or {} - + # Validate required fields - if not data.get('name'): - return jsonify({'error': 'Task name is required'}), 400 - if not data.get('project_id'): - return jsonify({'error': 'project_id is required'}), 400 - + if not data.get("name"): + return jsonify({"error": "Task name is required"}), 400 + if not data.get("project_id"): + return jsonify({"error": "project_id is required"}), 400 + # Create task task = Task( - name=data['name'], - description=data.get('description'), - project_id=data['project_id'], - status=data.get('status', 'todo'), - priority=data.get('priority', 1) + name=data["name"], + description=data.get("description"), + project_id=data["project_id"], + status=data.get("status", "todo"), + priority=data.get("priority", 1), ) - + db.session.add(task) db.session.commit() - - return jsonify({ - 'message': 'Task created successfully', - 'task': task.to_dict() - }), 201 + return jsonify({"message": "Task created successfully", "task": task.to_dict()}), 201 -@api_v1_bp.route('/tasks/', methods=['PUT', 'PATCH']) -@require_api_token('write:tasks') + +@api_v1_bp.route("/tasks/", methods=["PUT", "PATCH"]) +@require_api_token("write:tasks") def update_task(task_id): """Update a task --- @@ -988,27 +958,24 @@ def update_task(task_id): """ task = Task.query.get_or_404(task_id) data = request.get_json() or {} - + # Update fields - if 'name' in data: - task.name = data['name'] - if 'description' in data: - task.description = data['description'] - if 'status' in data: - task.status = data['status'] - if 'priority' in data: - task.priority = data['priority'] - + if "name" in data: + task.name = data["name"] + if "description" in data: + task.description = data["description"] + if "status" in data: + task.status = data["status"] + if "priority" in data: + task.priority = data["priority"] + db.session.commit() - - return jsonify({ - 'message': 'Task updated successfully', - 'task': task.to_dict() - }) + + return jsonify({"message": "Task updated successfully", "task": task.to_dict()}) -@api_v1_bp.route('/tasks/', methods=['DELETE']) -@require_api_token('write:tasks') +@api_v1_bp.route("/tasks/", methods=["DELETE"]) +@require_api_token("write:tasks") def delete_task(task_id): """Delete a task --- @@ -1028,17 +995,18 @@ def delete_task(task_id): description: Task not found """ task = Task.query.get_or_404(task_id) - + db.session.delete(task) db.session.commit() - - return jsonify({'message': 'Task deleted successfully'}) + + return jsonify({"message": "Task deleted successfully"}) # ==================== Clients ==================== -@api_v1_bp.route('/clients', methods=['GET']) -@require_api_token('read:clients') + +@api_v1_bp.route("/clients", methods=["GET"]) +@require_api_token("read:clients") def list_clients(): """List all clients --- @@ -1058,18 +1026,15 @@ def list_clients(): description: List of clients """ query = Client.query.order_by(Client.name) - + # Paginate result = paginate_query(query) - - return jsonify({ - 'clients': [c.to_dict() for c in result['items']], - 'pagination': result['pagination'] - }) + + return jsonify({"clients": [c.to_dict() for c in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/clients/', methods=['GET']) -@require_api_token('read:clients') +@api_v1_bp.route("/clients/", methods=["GET"]) +@require_api_token("read:clients") def get_client(client_id): """Get a specific client --- @@ -1089,11 +1054,11 @@ def get_client(client_id): description: Client not found """ client = Client.query.get_or_404(client_id) - return jsonify({'client': client.to_dict()}) + return jsonify({"client": client.to_dict()}) -@api_v1_bp.route('/clients', methods=['POST']) -@require_api_token('write:clients') +@api_v1_bp.route("/clients", methods=["POST"]) +@require_api_token("write:clients") def create_client(): """Create a new client --- @@ -1125,32 +1090,25 @@ def create_client(): description: Invalid input """ data = request.get_json() or {} - + # Validate required fields - if not data.get('name'): - return jsonify({'error': 'Client name is required'}), 400 - + if not data.get("name"): + return jsonify({"error": "Client name is required"}), 400 + # Create client - client = Client( - name=data['name'], - email=data.get('email'), - company=data.get('company'), - phone=data.get('phone') - ) - + client = Client(name=data["name"], email=data.get("email"), company=data.get("company"), phone=data.get("phone")) + db.session.add(client) db.session.commit() - - return jsonify({ - 'message': 'Client created successfully', - 'client': client.to_dict() - }), 201 + + return jsonify({"message": "Client created successfully", "client": client.to_dict()}), 201 # ==================== Invoices ==================== -@api_v1_bp.route('/invoices', methods=['GET']) -@require_api_token('read:invoices') + +@api_v1_bp.route("/invoices", methods=["GET"]) +@require_api_token("read:invoices") def list_invoices(): """List invoices --- @@ -1179,25 +1137,22 @@ def list_invoices(): description: List of invoices """ query = Invoice.query - status = request.args.get('status') + status = request.args.get("status") if status: query = query.filter(Invoice.status == status) - client_id = request.args.get('client_id', type=int) + client_id = request.args.get("client_id", type=int) if client_id: query = query.filter(Invoice.client_id == client_id) - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter(Invoice.project_id == project_id) query = query.order_by(Invoice.created_at.desc()) result = paginate_query(query) - return jsonify({ - 'invoices': [inv.to_dict() for inv in result['items']], - 'pagination': result['pagination'] - }) + return jsonify({"invoices": [inv.to_dict() for inv in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/invoices/', methods=['GET']) -@require_api_token('read:invoices') +@api_v1_bp.route("/invoices/", methods=["GET"]) +@require_api_token("read:invoices") def get_invoice(invoice_id): """Get invoice by id --- @@ -1212,11 +1167,11 @@ def get_invoice(invoice_id): description: Not found """ invoice = Invoice.query.get_or_404(invoice_id) - return jsonify({'invoice': invoice.to_dict()}) + return jsonify({"invoice": invoice.to_dict()}) -@api_v1_bp.route('/invoices', methods=['POST']) -@require_api_token('write:invoices') +@api_v1_bp.route("/invoices", methods=["POST"]) +@require_api_token("write:invoices") def create_invoice(): """Create a new invoice --- @@ -1255,40 +1210,40 @@ def create_invoice(): """ data = request.get_json() or {} # Validate required fields - required = ['project_id', 'client_id', 'client_name', 'due_date'] + required = ["project_id", "client_id", "client_name", "due_date"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 # Validate foreign keys - project = Project.query.get(data['project_id']) - client = Client.query.get(data['client_id']) + project = Project.query.get(data["project_id"]) + client = Client.query.get(data["client_id"]) if not project or not client: - return jsonify({'error': 'Invalid project_id or client_id'}), 400 - due_dt = _parse_date(data.get('due_date')) + return jsonify({"error": "Invalid project_id or client_id"}), 400 + due_dt = _parse_date(data.get("due_date")) if not due_dt: - return jsonify({'error': 'Invalid due_date format, expected YYYY-MM-DD'}), 400 - invoice_number = data.get('invoice_number') or Invoice.generate_invoice_number() + return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400 + invoice_number = data.get("invoice_number") or Invoice.generate_invoice_number() invoice = Invoice( invoice_number=invoice_number, project_id=project.id, - client_name=data['client_name'], + client_name=data["client_name"], client_id=client.id, due_date=due_dt, created_by=g.api_user.id, - client_email=data.get('client_email'), - client_address=data.get('client_address'), - notes=data.get('notes'), - terms=data.get('terms'), - tax_rate=data.get('tax_rate', 0), - currency_code=data.get('currency_code', 'EUR'), + client_email=data.get("client_email"), + client_address=data.get("client_address"), + notes=data.get("notes"), + terms=data.get("terms"), + tax_rate=data.get("tax_rate", 0), + currency_code=data.get("currency_code", "EUR"), ) db.session.add(invoice) db.session.commit() - return jsonify({'message': 'Invoice created successfully', 'invoice': invoice.to_dict()}), 201 + return jsonify({"message": "Invoice created successfully", "invoice": invoice.to_dict()}), 201 -@api_v1_bp.route('/invoices/', methods=['PUT', 'PATCH']) -@require_api_token('write:invoices') +@api_v1_bp.route("/invoices/", methods=["PUT", "PATCH"]) +@require_api_token("write:invoices") def update_invoice(invoice_id): """Update an invoice --- @@ -1305,31 +1260,32 @@ def update_invoice(invoice_id): invoice = Invoice.query.get_or_404(invoice_id) data = request.get_json() or {} # Update basic fields if present - for field in ('client_name', 'client_email', 'client_address', 'notes', 'terms', 'status', 'currency_code'): + for field in ("client_name", "client_email", "client_address", "notes", "terms", "status", "currency_code"): if field in data: setattr(invoice, field, data[field]) - if 'due_date' in data: - parsed = _parse_date(data['due_date']) + if "due_date" in data: + parsed = _parse_date(data["due_date"]) if parsed: invoice.due_date = parsed - if 'tax_rate' in data: + if "tax_rate" in data: try: - invoice.tax_rate = float(data['tax_rate']) + invoice.tax_rate = float(data["tax_rate"]) except Exception: pass - if 'amount_paid' in data: + if "amount_paid" in data: try: from decimal import Decimal - invoice.amount_paid = Decimal(str(data['amount_paid'])) + + invoice.amount_paid = Decimal(str(data["amount_paid"])) invoice.update_payment_status() except Exception: pass db.session.commit() - return jsonify({'message': 'Invoice updated successfully', 'invoice': invoice.to_dict()}) + return jsonify({"message": "Invoice updated successfully", "invoice": invoice.to_dict()}) -@api_v1_bp.route('/invoices/', methods=['DELETE']) -@require_api_token('write:invoices') +@api_v1_bp.route("/invoices/", methods=["DELETE"]) +@require_api_token("write:invoices") def delete_invoice(invoice_id): """Cancel an invoice (soft-delete) --- @@ -1344,15 +1300,16 @@ def delete_invoice(invoice_id): description: Not found """ invoice = Invoice.query.get_or_404(invoice_id) - invoice.status = 'cancelled' + invoice.status = "cancelled" db.session.commit() - return jsonify({'message': 'Invoice cancelled successfully'}) + return jsonify({"message": "Invoice cancelled successfully"}) # ==================== Expenses ==================== -@api_v1_bp.route('/expenses', methods=['GET']) -@require_api_token('read:expenses') + +@api_v1_bp.route("/expenses", methods=["GET"]) +@require_api_token("read:expenses") def list_expenses(): """List expenses --- @@ -1396,44 +1353,41 @@ def list_expenses(): """ query = Expense.query # Restrict by user if not admin - user_id = request.args.get('user_id', type=int) + user_id = request.args.get("user_id", type=int) if user_id: if g.api_user.is_admin or user_id == g.api_user.id: query = query.filter(Expense.user_id == user_id) else: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 else: if not g.api_user.is_admin: query = query.filter(Expense.user_id == g.api_user.id) # Other filters - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter(Expense.project_id == project_id) - client_id = request.args.get('client_id', type=int) + client_id = request.args.get("client_id", type=int) if client_id: query = query.filter(Expense.client_id == client_id) - status = request.args.get('status') + status = request.args.get("status") if status: query = query.filter(Expense.status == status) - category = request.args.get('category') + category = request.args.get("category") if category: query = query.filter(Expense.category == category) - start_date = _parse_date(request.args.get('start_date')) - end_date = _parse_date(request.args.get('end_date')) + start_date = _parse_date(request.args.get("start_date")) + end_date = _parse_date(request.args.get("end_date")) if start_date: query = query.filter(Expense.expense_date >= start_date) if end_date: query = query.filter(Expense.expense_date <= end_date) query = query.order_by(Expense.expense_date.desc(), Expense.created_at.desc()) result = paginate_query(query) - return jsonify({ - 'expenses': [e.to_dict() for e in result['items']], - 'pagination': result['pagination'] - }) + return jsonify({"expenses": [e.to_dict() for e in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/expenses/', methods=['GET']) -@require_api_token('read:expenses') +@api_v1_bp.route("/expenses/", methods=["GET"]) +@require_api_token("read:expenses") def get_expense(expense_id): """Get an expense --- @@ -1449,12 +1403,12 @@ def get_expense(expense_id): """ expense = Expense.query.get_or_404(expense_id) if not g.api_user.is_admin and expense.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'expense': expense.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"expense": expense.to_dict()}) -@api_v1_bp.route('/expenses', methods=['POST']) -@require_api_token('write:expenses') +@api_v1_bp.route("/expenses", methods=["POST"]) +@require_api_token("write:expenses") def create_expense(): """Create a new expense --- @@ -1494,44 +1448,45 @@ def create_expense(): description: Invalid input """ data = request.get_json() or {} - required = ['title', 'category', 'amount', 'expense_date'] + required = ["title", "category", "amount", "expense_date"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - exp_date = _parse_date(data.get('expense_date')) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + exp_date = _parse_date(data.get("expense_date")) if not exp_date: - return jsonify({'error': 'Invalid expense_date format, expected YYYY-MM-DD'}), 400 - pay_date = _parse_date(data.get('payment_date')) + return jsonify({"error": "Invalid expense_date format, expected YYYY-MM-DD"}), 400 + pay_date = _parse_date(data.get("payment_date")) from decimal import Decimal + try: - amount = Decimal(str(data['amount'])) + amount = Decimal(str(data["amount"])) except Exception: - return jsonify({'error': 'Invalid amount'}), 400 + return jsonify({"error": "Invalid amount"}), 400 expense = Expense( user_id=g.api_user.id, - title=data['title'], - category=data['category'], + title=data["title"], + category=data["category"], amount=amount, expense_date=exp_date, - description=data.get('description'), - project_id=data.get('project_id'), - client_id=data.get('client_id'), - currency_code=data.get('currency_code', 'EUR'), - tax_amount=data.get('tax_amount', 0), - tax_rate=data.get('tax_rate', 0), - payment_method=data.get('payment_method'), + description=data.get("description"), + project_id=data.get("project_id"), + client_id=data.get("client_id"), + currency_code=data.get("currency_code", "EUR"), + tax_amount=data.get("tax_amount", 0), + tax_rate=data.get("tax_rate", 0), + payment_method=data.get("payment_method"), payment_date=pay_date, - billable=data.get('billable', False), - reimbursable=data.get('reimbursable', True), - tags=data.get('tags'), + billable=data.get("billable", False), + reimbursable=data.get("reimbursable", True), + tags=data.get("tags"), ) db.session.add(expense) db.session.commit() - return jsonify({'message': 'Expense created successfully', 'expense': expense.to_dict()}), 201 + return jsonify({"message": "Expense created successfully", "expense": expense.to_dict()}), 201 -@api_v1_bp.route('/expenses/', methods=['PUT', 'PATCH']) -@require_api_token('write:expenses') +@api_v1_bp.route("/expenses/", methods=["PUT", "PATCH"]) +@require_api_token("write:expenses") def update_expense(expense_id): """Update an expense --- @@ -1547,33 +1502,34 @@ def update_expense(expense_id): """ expense = Expense.query.get_or_404(expense_id) if not g.api_user.is_admin and expense.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 data = request.get_json() or {} - for field in ('title', 'description', 'category', 'currency_code', 'payment_method', 'status', 'tags'): + for field in ("title", "description", "category", "currency_code", "payment_method", "status", "tags"): if field in data: setattr(expense, field, data[field]) - if 'amount' in data: + if "amount" in data: try: from decimal import Decimal - expense.amount = Decimal(str(data['amount'])) + + expense.amount = Decimal(str(data["amount"])) except Exception: pass - if 'expense_date' in data: - parsed = _parse_date(data['expense_date']) + if "expense_date" in data: + parsed = _parse_date(data["expense_date"]) if parsed: expense.expense_date = parsed - if 'payment_date' in data: - parsed = _parse_date(data['payment_date']) + if "payment_date" in data: + parsed = _parse_date(data["payment_date"]) expense.payment_date = parsed - for bfield in ('billable', 'reimbursable', 'reimbursed', 'invoiced'): + for bfield in ("billable", "reimbursable", "reimbursed", "invoiced"): if bfield in data: setattr(expense, bfield, bool(data[bfield])) db.session.commit() - return jsonify({'message': 'Expense updated successfully', 'expense': expense.to_dict()}) + return jsonify({"message": "Expense updated successfully", "expense": expense.to_dict()}) -@api_v1_bp.route('/expenses/', methods=['DELETE']) -@require_api_token('write:expenses') +@api_v1_bp.route("/expenses/", methods=["DELETE"]) +@require_api_token("write:expenses") def delete_expense(expense_id): """Reject an expense (soft-delete) --- @@ -1589,16 +1545,17 @@ def delete_expense(expense_id): """ expense = Expense.query.get_or_404(expense_id) if not g.api_user.is_admin and expense.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - expense.status = 'rejected' + return jsonify({"error": "Access denied"}), 403 + expense.status = "rejected" db.session.commit() - return jsonify({'message': 'Expense rejected successfully'}) + return jsonify({"message": "Expense rejected successfully"}) # ==================== Payments ==================== -@api_v1_bp.route('/payments', methods=['GET']) -@require_api_token('read:payments') + +@api_v1_bp.route("/payments", methods=["GET"]) +@require_api_token("read:payments") def list_payments(): """List payments --- @@ -1621,16 +1578,16 @@ def list_payments(): description: List of payments """ query = Payment.query - invoice_id = request.args.get('invoice_id', type=int) + invoice_id = request.args.get("invoice_id", type=int) if invoice_id: query = query.filter(Payment.invoice_id == invoice_id) query = query.order_by(Payment.created_at.desc()) result = paginate_query(query) - return jsonify({'payments': [p.to_dict() for p in result['items']], 'pagination': result['pagination']}) + return jsonify({"payments": [p.to_dict() for p in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/payments/', methods=['GET']) -@require_api_token('read:payments') +@api_v1_bp.route("/payments/", methods=["GET"]) +@require_api_token("read:payments") def get_payment(payment_id): """Get a payment --- @@ -1643,11 +1600,11 @@ def get_payment(payment_id): description: Payment """ payment = Payment.query.get_or_404(payment_id) - return jsonify({'payment': payment.to_dict()}) + return jsonify({"payment": payment.to_dict()}) -@api_v1_bp.route('/payments', methods=['POST']) -@require_api_token('write:payments') +@api_v1_bp.route("/payments", methods=["POST"]) +@require_api_token("write:payments") def create_payment(): """Create a payment --- @@ -1675,37 +1632,38 @@ def create_payment(): description: Payment created """ data = request.get_json() or {} - required = ['invoice_id', 'amount'] + required = ["invoice_id", "amount"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - inv = Invoice.query.get(data['invoice_id']) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + inv = Invoice.query.get(data["invoice_id"]) if not inv: - return jsonify({'error': 'Invalid invoice_id'}), 400 + return jsonify({"error": "Invalid invoice_id"}), 400 from decimal import Decimal + try: - amount = Decimal(str(data['amount'])) + amount = Decimal(str(data["amount"])) except Exception: - return jsonify({'error': 'Invalid amount'}), 400 - pay_date = _parse_date(data.get('payment_date')) + return jsonify({"error": "Invalid amount"}), 400 + pay_date = _parse_date(data.get("payment_date")) payment = Payment( invoice_id=inv.id, amount=amount, - currency=data.get('currency', 'EUR'), + currency=data.get("currency", "EUR"), payment_date=pay_date or None, - method=data.get('method'), - reference=data.get('reference'), - notes=data.get('notes'), - received_by=getattr(g.api_user, 'id', None), + method=data.get("method"), + reference=data.get("reference"), + notes=data.get("notes"), + received_by=getattr(g.api_user, "id", None), ) payment.calculate_net_amount() db.session.add(payment) db.session.commit() - return jsonify({'message': 'Payment created successfully', 'payment': payment.to_dict()}), 201 + return jsonify({"message": "Payment created successfully", "payment": payment.to_dict()}), 201 -@api_v1_bp.route('/payments/', methods=['PUT', 'PATCH']) -@require_api_token('write:payments') +@api_v1_bp.route("/payments/", methods=["PUT", "PATCH"]) +@require_api_token("write:payments") def update_payment(payment_id): """Update a payment --- @@ -1719,25 +1677,26 @@ def update_payment(payment_id): """ payment = Payment.query.get_or_404(payment_id) data = request.get_json() or {} - for field in ('currency', 'method', 'reference', 'notes', 'status'): + for field in ("currency", "method", "reference", "notes", "status"): if field in data: setattr(payment, field, data[field]) - if 'amount' in data: + if "amount" in data: try: from decimal import Decimal - payment.amount = Decimal(str(data['amount'])) + + payment.amount = Decimal(str(data["amount"])) except Exception: pass - if 'payment_date' in data: - parsed = _parse_date(data['payment_date']) + if "payment_date" in data: + parsed = _parse_date(data["payment_date"]) payment.payment_date = parsed payment.calculate_net_amount() db.session.commit() - return jsonify({'message': 'Payment updated successfully', 'payment': payment.to_dict()}) + return jsonify({"message": "Payment updated successfully", "payment": payment.to_dict()}) -@api_v1_bp.route('/payments/', methods=['DELETE']) -@require_api_token('write:payments') +@api_v1_bp.route("/payments/", methods=["DELETE"]) +@require_api_token("write:payments") def delete_payment(payment_id): """Delete a payment --- @@ -1752,13 +1711,14 @@ def delete_payment(payment_id): payment = Payment.query.get_or_404(payment_id) db.session.delete(payment) db.session.commit() - return jsonify({'message': 'Payment deleted successfully'}) + return jsonify({"message": "Payment deleted successfully"}) # ==================== Mileage ==================== -@api_v1_bp.route('/mileage', methods=['GET']) -@require_api_token('read:mileage') + +@api_v1_bp.route("/mileage", methods=["GET"]) +@require_api_token("read:mileage") def list_mileage(): """List mileage entries (non-admin see own only) --- @@ -1792,31 +1752,31 @@ def list_mileage(): description: List of mileage entries """ query = Mileage.query - user_id = request.args.get('user_id', type=int) + user_id = request.args.get("user_id", type=int) if user_id: if g.api_user.is_admin or user_id == g.api_user.id: query = query.filter(Mileage.user_id == user_id) else: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 else: if not g.api_user.is_admin: query = query.filter(Mileage.user_id == g.api_user.id) - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter(Mileage.project_id == project_id) - start_date = _parse_date(request.args.get('start_date')) - end_date = _parse_date(request.args.get('end_date')) + start_date = _parse_date(request.args.get("start_date")) + end_date = _parse_date(request.args.get("end_date")) if start_date: query = query.filter(Mileage.trip_date >= start_date) if end_date: query = query.filter(Mileage.trip_date <= end_date) query = query.order_by(Mileage.trip_date.desc(), Mileage.created_at.desc()) result = paginate_query(query) - return jsonify({'mileage': [m.to_dict() for m in result['items']], 'pagination': result['pagination']}) + return jsonify({"mileage": [m.to_dict() for m in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/mileage/', methods=['GET']) -@require_api_token('read:mileage') +@api_v1_bp.route("/mileage/", methods=["GET"]) +@require_api_token("read:mileage") def get_mileage(entry_id): """Get a mileage entry --- @@ -1827,12 +1787,12 @@ def get_mileage(entry_id): """ entry = Mileage.query.get_or_404(entry_id) if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'mileage': entry.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"mileage": entry.to_dict()}) -@api_v1_bp.route('/mileage', methods=['POST']) -@require_api_token('write:mileage') +@api_v1_bp.route("/mileage", methods=["POST"]) +@require_api_token("write:mileage") def create_mileage(): """Create a mileage entry --- @@ -1857,39 +1817,40 @@ def create_mileage(): is_round_trip: { type: boolean } """ data = request.get_json() or {} - required = ['trip_date', 'purpose', 'start_location', 'end_location', 'distance_km', 'rate_per_km'] + required = ["trip_date", "purpose", "start_location", "end_location", "distance_km", "rate_per_km"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - trip_date = _parse_date(data.get('trip_date')) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + trip_date = _parse_date(data.get("trip_date")) if not trip_date: - return jsonify({'error': 'Invalid trip_date format, expected YYYY-MM-DD'}), 400 + return jsonify({"error": "Invalid trip_date format, expected YYYY-MM-DD"}), 400 from decimal import Decimal + try: - distance_km = Decimal(str(data['distance_km'])) - rate_per_km = Decimal(str(data['rate_per_km'])) + distance_km = Decimal(str(data["distance_km"])) + rate_per_km = Decimal(str(data["rate_per_km"])) except Exception: - return jsonify({'error': 'Invalid distance_km or rate_per_km'}), 400 + return jsonify({"error": "Invalid distance_km or rate_per_km"}), 400 entry = Mileage( user_id=g.api_user.id, trip_date=trip_date, - purpose=data['purpose'], - start_location=data['start_location'], - end_location=data['end_location'], + purpose=data["purpose"], + start_location=data["start_location"], + end_location=data["end_location"], distance_km=distance_km, rate_per_km=rate_per_km, - project_id=data.get('project_id'), - client_id=data.get('client_id'), - is_round_trip=bool(data.get('is_round_trip', False)), - description=data.get('description'), + project_id=data.get("project_id"), + client_id=data.get("client_id"), + is_round_trip=bool(data.get("is_round_trip", False)), + description=data.get("description"), ) db.session.add(entry) db.session.commit() - return jsonify({'message': 'Mileage entry created successfully', 'mileage': entry.to_dict()}), 201 + return jsonify({"message": "Mileage entry created successfully", "mileage": entry.to_dict()}), 201 -@api_v1_bp.route('/mileage/', methods=['PUT', 'PATCH']) -@require_api_token('write:mileage') +@api_v1_bp.route("/mileage/", methods=["PUT", "PATCH"]) +@require_api_token("write:mileage") def update_mileage(entry_id): """Update a mileage entry --- @@ -1898,30 +1859,42 @@ def update_mileage(entry_id): """ entry = Mileage.query.get_or_404(entry_id) if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 data = request.get_json() or {} - for field in ('purpose', 'start_location', 'end_location', 'description', 'vehicle_type', 'vehicle_description', 'license_plate', 'currency_code', 'status', 'notes'): + for field in ( + "purpose", + "start_location", + "end_location", + "description", + "vehicle_type", + "vehicle_description", + "license_plate", + "currency_code", + "status", + "notes", + ): if field in data: setattr(entry, field, data[field]) - if 'trip_date' in data: - parsed = _parse_date(data['trip_date']) + if "trip_date" in data: + parsed = _parse_date(data["trip_date"]) if parsed: entry.trip_date = parsed - for numfield in ('distance_km', 'rate_per_km', 'start_odometer', 'end_odometer'): + for numfield in ("distance_km", "rate_per_km", "start_odometer", "end_odometer"): if numfield in data: try: from decimal import Decimal + setattr(entry, numfield, Decimal(str(data[numfield]))) except Exception: pass - if 'is_round_trip' in data: - entry.is_round_trip = bool(data['is_round_trip']) + if "is_round_trip" in data: + entry.is_round_trip = bool(data["is_round_trip"]) db.session.commit() - return jsonify({'message': 'Mileage entry updated successfully', 'mileage': entry.to_dict()}) + return jsonify({"message": "Mileage entry updated successfully", "mileage": entry.to_dict()}) -@api_v1_bp.route('/mileage/', methods=['DELETE']) -@require_api_token('write:mileage') +@api_v1_bp.route("/mileage/", methods=["DELETE"]) +@require_api_token("write:mileage") def delete_mileage(entry_id): """Reject a mileage entry --- @@ -1930,16 +1903,17 @@ def delete_mileage(entry_id): """ entry = Mileage.query.get_or_404(entry_id) if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - entry.status = 'rejected' + return jsonify({"error": "Access denied"}), 403 + entry.status = "rejected" db.session.commit() - return jsonify({'message': 'Mileage entry rejected successfully'}) + return jsonify({"message": "Mileage entry rejected successfully"}) # ==================== Per Diem ==================== -@api_v1_bp.route('/per-diems', methods=['GET']) -@require_api_token('read:per_diem') + +@api_v1_bp.route("/per-diems", methods=["GET"]) +@require_api_token("read:per_diem") def list_per_diems(): """List per diem claims (non-admin see own only) --- @@ -1950,11 +1924,11 @@ def list_per_diems(): if not g.api_user.is_admin: query = query.filter(PerDiem.user_id == g.api_user.id) result = paginate_query(query.order_by(PerDiem.start_date.desc())) - return jsonify({'per_diems': [p.to_dict() for p in result['items']], 'pagination': result['pagination']}) + return jsonify({"per_diems": [p.to_dict() for p in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/per-diems/', methods=['GET']) -@require_api_token('read:per_diem') +@api_v1_bp.route("/per-diems/", methods=["GET"]) +@require_api_token("read:per_diem") def get_per_diem(pd_id): """Get a per diem claim --- @@ -1963,12 +1937,12 @@ def get_per_diem(pd_id): """ pd = PerDiem.query.get_or_404(pd_id) if not g.api_user.is_admin and pd.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'per_diem': pd.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"per_diem": pd.to_dict()}) -@api_v1_bp.route('/per-diems', methods=['POST']) -@require_api_token('write:per_diem') +@api_v1_bp.route("/per-diems", methods=["POST"]) +@require_api_token("write:per_diem") def create_per_diem(): """Create a per diem claim --- @@ -1976,45 +1950,46 @@ def create_per_diem(): - PerDiem """ data = request.get_json() or {} - required = ['trip_purpose', 'start_date', 'end_date', 'country', 'full_day_rate', 'half_day_rate'] + required = ["trip_purpose", "start_date", "end_date", "country", "full_day_rate", "half_day_rate"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - sdate = _parse_date(data.get('start_date')) - edate = _parse_date(data.get('end_date')) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + sdate = _parse_date(data.get("start_date")) + edate = _parse_date(data.get("end_date")) if not sdate or not edate or edate < sdate: - return jsonify({'error': 'Invalid date range'}), 400 + return jsonify({"error": "Invalid date range"}), 400 from decimal import Decimal + try: - fdr = Decimal(str(data['full_day_rate'])) - hdr = Decimal(str(data['half_day_rate'])) + fdr = Decimal(str(data["full_day_rate"])) + hdr = Decimal(str(data["half_day_rate"])) except Exception: - return jsonify({'error': 'Invalid rates'}), 400 + return jsonify({"error": "Invalid rates"}), 400 pd = PerDiem( user_id=g.api_user.id, - trip_purpose=data['trip_purpose'], + trip_purpose=data["trip_purpose"], start_date=sdate, end_date=edate, - country=data['country'], + country=data["country"], full_day_rate=fdr, half_day_rate=hdr, - city=data.get('city'), - description=data.get('description'), - currency_code=data.get('currency_code', 'EUR'), - full_days=data.get('full_days', 0), - half_days=data.get('half_days', 0), - breakfast_provided=data.get('breakfast_provided', 0), - lunch_provided=data.get('lunch_provided', 0), - dinner_provided=data.get('dinner_provided', 0), + city=data.get("city"), + description=data.get("description"), + currency_code=data.get("currency_code", "EUR"), + full_days=data.get("full_days", 0), + half_days=data.get("half_days", 0), + breakfast_provided=data.get("breakfast_provided", 0), + lunch_provided=data.get("lunch_provided", 0), + dinner_provided=data.get("dinner_provided", 0), ) pd.recalculate_amount() db.session.add(pd) db.session.commit() - return jsonify({'message': 'Per diem created successfully', 'per_diem': pd.to_dict()}), 201 + return jsonify({"message": "Per diem created successfully", "per_diem": pd.to_dict()}), 201 -@api_v1_bp.route('/per-diems/', methods=['PUT', 'PATCH']) -@require_api_token('write:per_diem') +@api_v1_bp.route("/per-diems/", methods=["PUT", "PATCH"]) +@require_api_token("write:per_diem") def update_per_diem(pd_id): """Update a per diem claim --- @@ -2023,39 +1998,40 @@ def update_per_diem(pd_id): """ pd = PerDiem.query.get_or_404(pd_id) if not g.api_user.is_admin and pd.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 data = request.get_json() or {} - for field in ('trip_purpose', 'description', 'country', 'city', 'currency_code', 'status', 'notes'): + for field in ("trip_purpose", "description", "country", "city", "currency_code", "status", "notes"): if field in data: setattr(pd, field, data[field]) - for numfield in ('full_days', 'half_days', 'breakfast_provided', 'lunch_provided', 'dinner_provided'): + for numfield in ("full_days", "half_days", "breakfast_provided", "lunch_provided", "dinner_provided"): if numfield in data: try: setattr(pd, numfield, int(data[numfield])) except Exception: pass - for ratefield in ('full_day_rate', 'half_day_rate', 'breakfast_deduction', 'lunch_deduction', 'dinner_deduction'): + for ratefield in ("full_day_rate", "half_day_rate", "breakfast_deduction", "lunch_deduction", "dinner_deduction"): if ratefield in data: try: from decimal import Decimal + setattr(pd, ratefield, Decimal(str(data[ratefield]))) except Exception: pass - if 'start_date' in data: - parsed = _parse_date(data['start_date']) + if "start_date" in data: + parsed = _parse_date(data["start_date"]) if parsed: pd.start_date = parsed - if 'end_date' in data: - parsed = _parse_date(data['end_date']) + if "end_date" in data: + parsed = _parse_date(data["end_date"]) if parsed: pd.end_date = parsed pd.recalculate_amount() db.session.commit() - return jsonify({'message': 'Per diem updated successfully', 'per_diem': pd.to_dict()}) + return jsonify({"message": "Per diem updated successfully", "per_diem": pd.to_dict()}) -@api_v1_bp.route('/per-diems/', methods=['DELETE']) -@require_api_token('write:per_diem') +@api_v1_bp.route("/per-diems/", methods=["DELETE"]) +@require_api_token("write:per_diem") def delete_per_diem(pd_id): """Reject a per diem claim --- @@ -2064,14 +2040,14 @@ def delete_per_diem(pd_id): """ pd = PerDiem.query.get_or_404(pd_id) if not g.api_user.is_admin and pd.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - pd.status = 'rejected' + return jsonify({"error": "Access denied"}), 403 + pd.status = "rejected" db.session.commit() - return jsonify({'message': 'Per diem rejected successfully'}) + return jsonify({"message": "Per diem rejected successfully"}) -@api_v1_bp.route('/per-diem-rates', methods=['GET']) -@require_api_token('read:per_diem') +@api_v1_bp.route("/per-diem-rates", methods=["GET"]) +@require_api_token("read:per_diem") def list_per_diem_rates(): """List per diem rates --- @@ -2080,11 +2056,11 @@ def list_per_diem_rates(): """ query = PerDiemRate.query.filter(PerDiemRate.is_active == True) result = paginate_query(query.order_by(PerDiemRate.country.asc(), PerDiemRate.city.asc())) - return jsonify({'rates': [r.to_dict() for r in result['items']], 'pagination': result['pagination']}) + return jsonify({"rates": [r.to_dict() for r in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/per-diem-rates', methods=['POST']) -@require_api_token('admin:all') +@api_v1_bp.route("/per-diem-rates", methods=["POST"]) +@require_api_token("admin:all") def create_per_diem_rate(): """Create a per diem rate (admin) --- @@ -2092,42 +2068,44 @@ def create_per_diem_rate(): - PerDiemRates """ data = request.get_json() or {} - required = ['country', 'full_day_rate', 'half_day_rate', 'effective_from'] + required = ["country", "full_day_rate", "half_day_rate", "effective_from"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - eff_from = _parse_date(data.get('effective_from')) - eff_to = _parse_date(data.get('effective_to')) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + eff_from = _parse_date(data.get("effective_from")) + eff_to = _parse_date(data.get("effective_to")) from decimal import Decimal + try: - fdr = Decimal(str(data['full_day_rate'])) - hdr = Decimal(str(data['half_day_rate'])) + fdr = Decimal(str(data["full_day_rate"])) + hdr = Decimal(str(data["half_day_rate"])) except Exception: - return jsonify({'error': 'Invalid rates'}), 400 + return jsonify({"error": "Invalid rates"}), 400 rate = PerDiemRate( - country=data['country'], + country=data["country"], full_day_rate=fdr, half_day_rate=hdr, effective_from=eff_from, effective_to=eff_to, - city=data.get('city'), - currency_code=data.get('currency_code', 'EUR'), - breakfast_rate=data.get('breakfast_rate'), - lunch_rate=data.get('lunch_rate'), - dinner_rate=data.get('dinner_rate'), - incidental_rate=data.get('incidental_rate'), - is_active=bool(data.get('is_active', True)), - notes=data.get('notes'), + city=data.get("city"), + currency_code=data.get("currency_code", "EUR"), + breakfast_rate=data.get("breakfast_rate"), + lunch_rate=data.get("lunch_rate"), + dinner_rate=data.get("dinner_rate"), + incidental_rate=data.get("incidental_rate"), + is_active=bool(data.get("is_active", True)), + notes=data.get("notes"), ) db.session.add(rate) db.session.commit() - return jsonify({'message': 'Per diem rate created successfully', 'rate': rate.to_dict()}), 201 + return jsonify({"message": "Per diem rate created successfully", "rate": rate.to_dict()}), 201 # ==================== Budget Alerts ==================== -@api_v1_bp.route('/budget-alerts', methods=['GET']) -@require_api_token('read:budget_alerts') + +@api_v1_bp.route("/budget-alerts", methods=["GET"]) +@require_api_token("read:budget_alerts") def list_budget_alerts(): """List budget alerts --- @@ -2135,15 +2113,15 @@ def list_budget_alerts(): - BudgetAlerts """ query = BudgetAlert.query - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter(BudgetAlert.project_id == project_id) result = paginate_query(query.order_by(BudgetAlert.created_at.desc())) - return jsonify({'alerts': [a.to_dict() for a in result['items']], 'pagination': result['pagination']}) + return jsonify({"alerts": [a.to_dict() for a in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/budget-alerts', methods=['POST']) -@require_api_token('admin:all') +@api_v1_bp.route("/budget-alerts", methods=["POST"]) +@require_api_token("admin:all") def create_budget_alert(): """Create a budget alert (admin) --- @@ -2151,26 +2129,26 @@ def create_budget_alert(): - BudgetAlerts """ data = request.get_json() or {} - required = ['project_id', 'alert_type', 'budget_consumed_percent', 'budget_amount', 'consumed_amount', 'message'] + required = ["project_id", "alert_type", "budget_consumed_percent", "budget_amount", "consumed_amount", "message"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 alert = BudgetAlert( - project_id=data['project_id'], - alert_type=data['alert_type'], - alert_level=data.get('alert_level', 'info'), - budget_consumed_percent=data['budget_consumed_percent'], - budget_amount=data['budget_amount'], - consumed_amount=data['consumed_amount'], - message=data['message'], + project_id=data["project_id"], + alert_type=data["alert_type"], + alert_level=data.get("alert_level", "info"), + budget_consumed_percent=data["budget_consumed_percent"], + budget_amount=data["budget_amount"], + consumed_amount=data["consumed_amount"], + message=data["message"], ) db.session.add(alert) db.session.commit() - return jsonify({'message': 'Budget alert created successfully', 'alert': alert.to_dict()}), 201 + return jsonify({"message": "Budget alert created successfully", "alert": alert.to_dict()}), 201 -@api_v1_bp.route('/budget-alerts//ack', methods=['POST']) -@require_api_token('write:budget_alerts') +@api_v1_bp.route("/budget-alerts//ack", methods=["POST"]) +@require_api_token("write:budget_alerts") def acknowledge_budget_alert(alert_id): """Acknowledge a budget alert --- @@ -2179,13 +2157,14 @@ def acknowledge_budget_alert(alert_id): """ alert = BudgetAlert.query.get_or_404(alert_id) alert.acknowledge(g.api_user.id) - return jsonify({'message': 'Alert acknowledged'}) + return jsonify({"message": "Alert acknowledged"}) # ==================== Calendar Events ==================== -@api_v1_bp.route('/calendar/events', methods=['GET']) -@require_api_token('read:calendar') + +@api_v1_bp.route("/calendar/events", methods=["GET"]) +@require_api_token("read:calendar") def list_calendar_events(): """List calendar events for current user --- @@ -2199,8 +2178,8 @@ def list_calendar_events(): in: query type: string """ - start = request.args.get('start') - end = request.args.get('end') + start = request.args.get("start") + end = request.args.get("end") start_dt = parse_datetime(start) if start else None end_dt = parse_datetime(end) if end else None query = CalendarEvent.query.filter(CalendarEvent.user_id == g.api_user.id) @@ -2209,11 +2188,11 @@ def list_calendar_events(): if end_dt: query = query.filter(CalendarEvent.start_time <= end_dt) events = query.order_by(CalendarEvent.start_time.asc()).all() - return jsonify({'events': [e.to_dict() for e in events]}) + return jsonify({"events": [e.to_dict() for e in events]}) -@api_v1_bp.route('/calendar/events/', methods=['GET']) -@require_api_token('read:calendar') +@api_v1_bp.route("/calendar/events/", methods=["GET"]) +@require_api_token("read:calendar") def get_calendar_event(event_id): """Get calendar event --- @@ -2222,12 +2201,12 @@ def get_calendar_event(event_id): """ ev = CalendarEvent.query.get_or_404(event_id) if not g.api_user.is_admin and ev.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'event': ev.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"event": ev.to_dict()}) -@api_v1_bp.route('/calendar/events', methods=['POST']) -@require_api_token('write:calendar') +@api_v1_bp.route("/calendar/events", methods=["POST"]) +@require_api_token("write:calendar") def create_calendar_event(): """Create calendar event --- @@ -2235,37 +2214,37 @@ def create_calendar_event(): - Calendar """ data = request.get_json() or {} - required = ['title', 'start_time', 'end_time'] + required = ["title", "start_time", "end_time"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - start_dt = parse_datetime(data['start_time']) - end_dt = parse_datetime(data['end_time']) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + start_dt = parse_datetime(data["start_time"]) + end_dt = parse_datetime(data["end_time"]) if not start_dt or not end_dt or end_dt <= start_dt: - return jsonify({'error': 'Invalid start/end time'}), 400 + return jsonify({"error": "Invalid start/end time"}), 400 ev = CalendarEvent( user_id=g.api_user.id, - title=data['title'], + title=data["title"], start_time=start_dt, end_time=end_dt, - description=data.get('description'), - all_day=bool(data.get('all_day', False)), - location=data.get('location'), - project_id=data.get('project_id'), - task_id=data.get('task_id'), - client_id=data.get('client_id'), - event_type=data.get('event_type', 'event'), - reminder_minutes=data.get('reminder_minutes'), - color=data.get('color'), - is_private=bool(data.get('is_private', False)), + description=data.get("description"), + all_day=bool(data.get("all_day", False)), + location=data.get("location"), + project_id=data.get("project_id"), + task_id=data.get("task_id"), + client_id=data.get("client_id"), + event_type=data.get("event_type", "event"), + reminder_minutes=data.get("reminder_minutes"), + color=data.get("color"), + is_private=bool(data.get("is_private", False)), ) db.session.add(ev) db.session.commit() - return jsonify({'message': 'Event created successfully', 'event': ev.to_dict()}), 201 + return jsonify({"message": "Event created successfully", "event": ev.to_dict()}), 201 -@api_v1_bp.route('/calendar/events/', methods=['PUT', 'PATCH']) -@require_api_token('write:calendar') +@api_v1_bp.route("/calendar/events/", methods=["PUT", "PATCH"]) +@require_api_token("write:calendar") def update_calendar_event(event_id): """Update calendar event --- @@ -2274,25 +2253,25 @@ def update_calendar_event(event_id): """ ev = CalendarEvent.query.get_or_404(event_id) if not g.api_user.is_admin and ev.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 data = request.get_json() or {} - for field in ('title', 'description', 'location', 'event_type', 'color', 'is_private', 'reminder_minutes'): + for field in ("title", "description", "location", "event_type", "color", "is_private", "reminder_minutes"): if field in data: setattr(ev, field, data[field]) - if 'start_time' in data: - parsed = parse_datetime(data['start_time']) + if "start_time" in data: + parsed = parse_datetime(data["start_time"]) if parsed: ev.start_time = parsed - if 'end_time' in data: - parsed = parse_datetime(data['end_time']) + if "end_time" in data: + parsed = parse_datetime(data["end_time"]) if parsed: ev.end_time = parsed db.session.commit() - return jsonify({'message': 'Event updated successfully', 'event': ev.to_dict()}) + return jsonify({"message": "Event updated successfully", "event": ev.to_dict()}) -@api_v1_bp.route('/calendar/events/', methods=['DELETE']) -@require_api_token('write:calendar') +@api_v1_bp.route("/calendar/events/", methods=["DELETE"]) +@require_api_token("write:calendar") def delete_calendar_event(event_id): """Delete calendar event --- @@ -2301,16 +2280,17 @@ def delete_calendar_event(event_id): """ ev = CalendarEvent.query.get_or_404(event_id) if not g.api_user.is_admin and ev.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 db.session.delete(ev) db.session.commit() - return jsonify({'message': 'Event deleted successfully'}) + return jsonify({"message": "Event deleted successfully"}) # ==================== Kanban Columns ==================== -@api_v1_bp.route('/kanban/columns', methods=['GET']) -@require_api_token('read:tasks') + +@api_v1_bp.route("/kanban/columns", methods=["GET"]) +@require_api_token("read:tasks") def list_kanban_columns(): """List kanban columns --- @@ -2321,13 +2301,13 @@ def list_kanban_columns(): in: query type: integer """ - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) cols = KanbanColumn.get_all_columns(project_id=project_id) - return jsonify({'columns': [c.to_dict() for c in cols]}) + return jsonify({"columns": [c.to_dict() for c in cols]}) -@api_v1_bp.route('/kanban/columns', methods=['POST']) -@require_api_token('write:tasks') +@api_v1_bp.route("/kanban/columns", methods=["POST"]) +@require_api_token("write:tasks") def create_kanban_column(): """Create kanban column --- @@ -2335,28 +2315,28 @@ def create_kanban_column(): - Kanban """ data = request.get_json() or {} - required = ['key', 'label'] + required = ["key", "label"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 col = KanbanColumn( - key=data['key'], - label=data['label'], - icon=data.get('icon', 'fas fa-circle'), - color=data.get('color', 'secondary'), - position=data.get('position', 0), - is_active=bool(data.get('is_active', True)), - is_system=bool(data.get('is_system', False)), - is_complete_state=bool(data.get('is_complete_state', False)), - project_id=data.get('project_id'), + key=data["key"], + label=data["label"], + icon=data.get("icon", "fas fa-circle"), + color=data.get("color", "secondary"), + position=data.get("position", 0), + is_active=bool(data.get("is_active", True)), + is_system=bool(data.get("is_system", False)), + is_complete_state=bool(data.get("is_complete_state", False)), + project_id=data.get("project_id"), ) db.session.add(col) db.session.commit() - return jsonify({'message': 'Column created successfully', 'column': col.to_dict()}), 201 + return jsonify({"message": "Column created successfully", "column": col.to_dict()}), 201 -@api_v1_bp.route('/kanban/columns/', methods=['PUT', 'PATCH']) -@require_api_token('write:tasks') +@api_v1_bp.route("/kanban/columns/", methods=["PUT", "PATCH"]) +@require_api_token("write:tasks") def update_kanban_column(col_id): """Update kanban column --- @@ -2365,15 +2345,15 @@ def update_kanban_column(col_id): """ col = KanbanColumn.query.get_or_404(col_id) data = request.get_json() or {} - for field in ('key', 'label', 'icon', 'color', 'position', 'is_active', 'is_complete_state'): + for field in ("key", "label", "icon", "color", "position", "is_active", "is_complete_state"): if field in data: setattr(col, field, data[field]) db.session.commit() - return jsonify({'message': 'Column updated successfully', 'column': col.to_dict()}) + return jsonify({"message": "Column updated successfully", "column": col.to_dict()}) -@api_v1_bp.route('/kanban/columns/', methods=['DELETE']) -@require_api_token('write:tasks') +@api_v1_bp.route("/kanban/columns/", methods=["DELETE"]) +@require_api_token("write:tasks") def delete_kanban_column(col_id): """Delete kanban column --- @@ -2382,14 +2362,14 @@ def delete_kanban_column(col_id): """ col = KanbanColumn.query.get_or_404(col_id) if col.is_system: - return jsonify({'error': 'Cannot delete system column'}), 400 + return jsonify({"error": "Cannot delete system column"}), 400 db.session.delete(col) db.session.commit() - return jsonify({'message': 'Column deleted successfully'}) + return jsonify({"message": "Column deleted successfully"}) -@api_v1_bp.route('/kanban/columns/reorder', methods=['POST']) -@require_api_token('write:tasks') +@api_v1_bp.route("/kanban/columns/reorder", methods=["POST"]) +@require_api_token("write:tasks") def reorder_kanban_columns(): """Reorder kanban columns --- @@ -2397,18 +2377,19 @@ def reorder_kanban_columns(): - Kanban """ data = request.get_json() or {} - ids = data.get('column_ids') or [] - project_id = data.get('project_id') + ids = data.get("column_ids") or [] + project_id = data.get("project_id") if not isinstance(ids, list) or not ids: - return jsonify({'error': 'column_ids must be a non-empty list'}), 400 + return jsonify({"error": "column_ids must be a non-empty list"}), 400 KanbanColumn.reorder_columns(ids, project_id=project_id) - return jsonify({'message': 'Columns reordered successfully'}) + return jsonify({"message": "Columns reordered successfully"}) # ==================== Saved Filters ==================== -@api_v1_bp.route('/saved-filters', methods=['GET']) -@require_api_token('read:filters') + +@api_v1_bp.route("/saved-filters", methods=["GET"]) +@require_api_token("read:filters") def list_saved_filters(): """List saved filters for current user --- @@ -2417,11 +2398,11 @@ def list_saved_filters(): """ query = SavedFilter.query.filter(SavedFilter.user_id == g.api_user.id) result = paginate_query(query.order_by(SavedFilter.created_at.desc())) - return jsonify({'filters': [f.to_dict() for f in result['items']], 'pagination': result['pagination']}) + return jsonify({"filters": [f.to_dict() for f in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/saved-filters/', methods=['GET']) -@require_api_token('read:filters') +@api_v1_bp.route("/saved-filters/", methods=["GET"]) +@require_api_token("read:filters") def get_saved_filter(filter_id): """Get saved filter --- @@ -2430,12 +2411,12 @@ def get_saved_filter(filter_id): """ sf = SavedFilter.query.get_or_404(filter_id) if sf.user_id != g.api_user.id and not (sf.is_shared or g.api_user.is_admin): - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'filter': sf.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"filter": sf.to_dict()}) -@api_v1_bp.route('/saved-filters', methods=['POST']) -@require_api_token('write:filters') +@api_v1_bp.route("/saved-filters", methods=["POST"]) +@require_api_token("write:filters") def create_saved_filter(): """Create saved filter --- @@ -2443,24 +2424,24 @@ def create_saved_filter(): - SavedFilters """ data = request.get_json() or {} - required = ['name', 'scope', 'payload'] + required = ["name", "scope", "payload"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 sf = SavedFilter( user_id=g.api_user.id, - name=data['name'], - scope=data['scope'], - payload=data['payload'], - is_shared=bool(data.get('is_shared', False)), + name=data["name"], + scope=data["scope"], + payload=data["payload"], + is_shared=bool(data.get("is_shared", False)), ) db.session.add(sf) db.session.commit() - return jsonify({'message': 'Saved filter created successfully', 'filter': sf.to_dict()}), 201 + return jsonify({"message": "Saved filter created successfully", "filter": sf.to_dict()}), 201 -@api_v1_bp.route('/saved-filters/', methods=['PUT', 'PATCH']) -@require_api_token('write:filters') +@api_v1_bp.route("/saved-filters/", methods=["PUT", "PATCH"]) +@require_api_token("write:filters") def update_saved_filter(filter_id): """Update saved filter --- @@ -2469,17 +2450,17 @@ def update_saved_filter(filter_id): """ sf = SavedFilter.query.get_or_404(filter_id) if sf.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 data = request.get_json() or {} - for field in ('name', 'scope', 'payload', 'is_shared'): + for field in ("name", "scope", "payload", "is_shared"): if field in data: setattr(sf, field, data[field]) db.session.commit() - return jsonify({'message': 'Saved filter updated successfully', 'filter': sf.to_dict()}) + return jsonify({"message": "Saved filter updated successfully", "filter": sf.to_dict()}) -@api_v1_bp.route('/saved-filters/', methods=['DELETE']) -@require_api_token('write:filters') +@api_v1_bp.route("/saved-filters/", methods=["DELETE"]) +@require_api_token("write:filters") def delete_saved_filter(filter_id): """Delete saved filter --- @@ -2488,16 +2469,17 @@ def delete_saved_filter(filter_id): """ sf = SavedFilter.query.get_or_404(filter_id) if sf.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 db.session.delete(sf) db.session.commit() - return jsonify({'message': 'Saved filter deleted successfully'}) + return jsonify({"message": "Saved filter deleted successfully"}) # ==================== Time Entry Templates ==================== -@api_v1_bp.route('/time-entry-templates', methods=['GET']) -@require_api_token('read:time_entries') + +@api_v1_bp.route("/time-entry-templates", methods=["GET"]) +@require_api_token("read:time_entries") def list_time_entry_templates(): """List time entry templates for current user --- @@ -2506,11 +2488,11 @@ def list_time_entry_templates(): """ query = TimeEntryTemplate.query.filter(TimeEntryTemplate.user_id == g.api_user.id) result = paginate_query(query.order_by(TimeEntryTemplate.created_at.desc())) - return jsonify({'templates': [t.to_dict() for t in result['items']], 'pagination': result['pagination']}) + return jsonify({"templates": [t.to_dict() for t in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/time-entry-templates/', methods=['GET']) -@require_api_token('read:time_entries') +@api_v1_bp.route("/time-entry-templates/", methods=["GET"]) +@require_api_token("read:time_entries") def get_time_entry_template(tpl_id): """Get time entry template --- @@ -2519,12 +2501,12 @@ def get_time_entry_template(tpl_id): """ tpl = TimeEntryTemplate.query.get_or_404(tpl_id) if tpl.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'template': tpl.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"template": tpl.to_dict()}) -@api_v1_bp.route('/time-entry-templates', methods=['POST']) -@require_api_token('write:time_entries') +@api_v1_bp.route("/time-entry-templates", methods=["POST"]) +@require_api_token("write:time_entries") def create_time_entry_template(): """Create time entry template --- @@ -2532,28 +2514,28 @@ def create_time_entry_template(): - TimeEntryTemplates """ data = request.get_json() or {} - required = ['name'] + required = ["name"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 tpl = TimeEntryTemplate( user_id=g.api_user.id, - name=data['name'], - description=data.get('description'), - project_id=data.get('project_id'), - task_id=data.get('task_id'), - default_duration_minutes=data.get('default_duration_minutes'), - default_notes=data.get('default_notes'), - tags=data.get('tags'), - billable=bool(data.get('billable', True)), + name=data["name"], + description=data.get("description"), + project_id=data.get("project_id"), + task_id=data.get("task_id"), + default_duration_minutes=data.get("default_duration_minutes"), + default_notes=data.get("default_notes"), + tags=data.get("tags"), + billable=bool(data.get("billable", True)), ) db.session.add(tpl) db.session.commit() - return jsonify({'message': 'Template created successfully', 'template': tpl.to_dict()}), 201 + return jsonify({"message": "Template created successfully", "template": tpl.to_dict()}), 201 -@api_v1_bp.route('/time-entry-templates/', methods=['PUT', 'PATCH']) -@require_api_token('write:time_entries') +@api_v1_bp.route("/time-entry-templates/", methods=["PUT", "PATCH"]) +@require_api_token("write:time_entries") def update_time_entry_template(tpl_id): """Update time entry template --- @@ -2562,17 +2544,26 @@ def update_time_entry_template(tpl_id): """ tpl = TimeEntryTemplate.query.get_or_404(tpl_id) if tpl.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 data = request.get_json() or {} - for field in ('name', 'description', 'project_id', 'task_id', 'default_duration_minutes', 'default_notes', 'tags', 'billable'): + for field in ( + "name", + "description", + "project_id", + "task_id", + "default_duration_minutes", + "default_notes", + "tags", + "billable", + ): if field in data: setattr(tpl, field, data[field]) db.session.commit() - return jsonify({'message': 'Template updated successfully', 'template': tpl.to_dict()}) + return jsonify({"message": "Template updated successfully", "template": tpl.to_dict()}) -@api_v1_bp.route('/time-entry-templates/', methods=['DELETE']) -@require_api_token('write:time_entries') +@api_v1_bp.route("/time-entry-templates/", methods=["DELETE"]) +@require_api_token("write:time_entries") def delete_time_entry_template(tpl_id): """Delete time entry template --- @@ -2581,16 +2572,17 @@ def delete_time_entry_template(tpl_id): """ tpl = TimeEntryTemplate.query.get_or_404(tpl_id) if tpl.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 db.session.delete(tpl) db.session.commit() - return jsonify({'message': 'Template deleted successfully'}) + return jsonify({"message": "Template deleted successfully"}) # ==================== Comments ==================== -@api_v1_bp.route('/comments', methods=['GET']) -@require_api_token('read:comments') + +@api_v1_bp.route("/comments", methods=["GET"]) +@require_api_token("read:comments") def list_comments(): """List comments by project or task --- @@ -2604,19 +2596,19 @@ def list_comments(): in: query type: integer """ - project_id = request.args.get('project_id', type=int) - task_id = request.args.get('task_id', type=int) + project_id = request.args.get("project_id", type=int) + task_id = request.args.get("task_id", type=int) if not project_id and not task_id: - return jsonify({'error': 'project_id or task_id is required'}), 400 + return jsonify({"error": "project_id or task_id is required"}), 400 if project_id: comments = Comment.get_project_comments(project_id) else: comments = Comment.get_task_comments(task_id) - return jsonify({'comments': [c.to_dict() for c in comments]}) + return jsonify({"comments": [c.to_dict() for c in comments]}) -@api_v1_bp.route('/comments', methods=['POST']) -@require_api_token('write:comments') +@api_v1_bp.route("/comments", methods=["POST"]) +@require_api_token("write:comments") def create_comment(): """Create comment --- @@ -2624,21 +2616,23 @@ def create_comment(): - Comments """ data = request.get_json() or {} - content = (data.get('content') or '').strip() - project_id = data.get('project_id') - task_id = data.get('task_id') + content = (data.get("content") or "").strip() + project_id = data.get("project_id") + task_id = data.get("task_id") if not content: - return jsonify({'error': 'content is required'}), 400 + return jsonify({"error": "content is required"}), 400 if (not project_id and not task_id) or (project_id and task_id): - return jsonify({'error': 'Provide either project_id or task_id'}), 400 - cmt = Comment(content=content, user_id=g.api_user.id, project_id=project_id, task_id=task_id, parent_id=data.get('parent_id')) + return jsonify({"error": "Provide either project_id or task_id"}), 400 + cmt = Comment( + content=content, user_id=g.api_user.id, project_id=project_id, task_id=task_id, parent_id=data.get("parent_id") + ) db.session.add(cmt) db.session.commit() - return jsonify({'message': 'Comment created successfully', 'comment': cmt.to_dict()}), 201 + return jsonify({"message": "Comment created successfully", "comment": cmt.to_dict()}), 201 -@api_v1_bp.route('/quotes', methods=['GET']) -@require_api_token('read:quotes') +@api_v1_bp.route("/quotes", methods=["GET"]) +@require_api_token("read:quotes") def list_quotes(): """List quotes --- @@ -2646,23 +2640,24 @@ def list_quotes(): - Quotes """ from app.models import Quote - status = request.args.get('status') - client_id = request.args.get('client_id', type=int) - limit = request.args.get('limit', 100, type=int) - offset = request.args.get('offset', 0, type=int) - + + status = request.args.get("status") + client_id = request.args.get("client_id", type=int) + limit = request.args.get("limit", 100, type=int) + offset = request.args.get("offset", 0, type=int) + query = Quote.query if status: query = query.filter_by(status=status) if client_id: query = query.filter_by(client_id=client_id) - + quotes = query.order_by(Quote.created_at.desc()).limit(limit).offset(offset).all() - return jsonify({'quotes': [q.to_dict() for q in quotes]}), 200 + return jsonify({"quotes": [q.to_dict() for q in quotes]}), 200 -@api_v1_bp.route('/quotes/', methods=['GET']) -@require_api_token('read:quotes') +@api_v1_bp.route("/quotes/", methods=["GET"]) +@require_api_token("read:quotes") def get_quote(quote_id): """Get quote --- @@ -2670,12 +2665,13 @@ def get_quote(quote_id): - Quotes """ from app.models import Quote + quote = Quote.query.get_or_404(quote_id) - return jsonify({'quote': quote.to_dict()}), 200 + return jsonify({"quote": quote.to_dict()}), 200 -@api_v1_bp.route('/quotes', methods=['POST']) -@require_api_token('write:quotes') +@api_v1_bp.route("/quotes", methods=["POST"]) +@require_api_token("write:quotes") def create_quote(): """Create quote --- @@ -2684,51 +2680,51 @@ def create_quote(): """ from app.models import Quote, QuoteItem from decimal import Decimal - + data = request.get_json() or {} - quote_number = data.get('quote_number') or Quote.generate_quote_number() - client_id = data.get('client_id') - title = data.get('title', '').strip() - + quote_number = data.get("quote_number") or Quote.generate_quote_number() + client_id = data.get("client_id") + title = data.get("title", "").strip() + if not client_id or not title: - return jsonify({'error': 'client_id and title are required'}), 400 - + return jsonify({"error": "client_id and title are required"}), 400 + quote = Quote( quote_number=quote_number, client_id=client_id, title=title, created_by=g.api_user.id, - description=data.get('description'), - tax_rate=Decimal(str(data.get('tax_rate', 0))), - currency_code=data.get('currency_code', 'EUR'), - payment_terms=data.get('payment_terms'), - requires_approval=data.get('requires_approval', False), - approval_level=data.get('approval_level', 1) + description=data.get("description"), + tax_rate=Decimal(str(data.get("tax_rate", 0))), + currency_code=data.get("currency_code", "EUR"), + payment_terms=data.get("payment_terms"), + requires_approval=data.get("requires_approval", False), + approval_level=data.get("approval_level", 1), ) - + db.session.add(quote) db.session.flush() - + # Add items - items = data.get('items', []) + items = data.get("items", []) for item_data in items: item = QuoteItem( quote_id=quote.id, - description=item_data.get('description', ''), - quantity=Decimal(str(item_data.get('quantity', 1))), - unit_price=Decimal(str(item_data.get('unit_price', 0))), - unit=item_data.get('unit') + description=item_data.get("description", ""), + quantity=Decimal(str(item_data.get("quantity", 1))), + unit_price=Decimal(str(item_data.get("unit_price", 0))), + unit=item_data.get("unit"), ) db.session.add(item) - + quote.calculate_totals() db.session.commit() - - return jsonify({'message': 'Quote created successfully', 'quote': quote.to_dict()}), 201 + return jsonify({"message": "Quote created successfully", "quote": quote.to_dict()}), 201 -@api_v1_bp.route('/quotes/', methods=['PUT', 'PATCH']) -@require_api_token('write:quotes') + +@api_v1_bp.route("/quotes/", methods=["PUT", "PATCH"]) +@require_api_token("write:quotes") def update_quote(quote_id): """Update quote --- @@ -2737,49 +2733,49 @@ def update_quote(quote_id): """ from app.models import Quote, QuoteItem from decimal import Decimal - + quote = Quote.query.get_or_404(quote_id) data = request.get_json() or {} - + # Update fields - if 'title' in data: - quote.title = data['title'].strip() - if 'description' in data: - quote.description = data['description'].strip() if data['description'] else None - if 'tax_rate' in data: - quote.tax_rate = Decimal(str(data['tax_rate'])) - if 'currency_code' in data: - quote.currency_code = data['currency_code'] - if 'payment_terms' in data: - quote.payment_terms = data['payment_terms'] - if 'status' in data: - quote.status = data['status'] - + if "title" in data: + quote.title = data["title"].strip() + if "description" in data: + quote.description = data["description"].strip() if data["description"] else None + if "tax_rate" in data: + quote.tax_rate = Decimal(str(data["tax_rate"])) + if "currency_code" in data: + quote.currency_code = data["currency_code"] + if "payment_terms" in data: + quote.payment_terms = data["payment_terms"] + if "status" in data: + quote.status = data["status"] + # Update items if provided - if 'items' in data: + if "items" in data: # Delete existing items for item in quote.items: db.session.delete(item) - + # Add new items - for item_data in data['items']: + for item_data in data["items"]: item = QuoteItem( quote_id=quote.id, - description=item_data.get('description', ''), - quantity=Decimal(str(item_data.get('quantity', 1))), - unit_price=Decimal(str(item_data.get('unit_price', 0))), - unit=item_data.get('unit') + description=item_data.get("description", ""), + quantity=Decimal(str(item_data.get("quantity", 1))), + unit_price=Decimal(str(item_data.get("unit_price", 0))), + unit=item_data.get("unit"), ) db.session.add(item) - + quote.calculate_totals() db.session.commit() - - return jsonify({'message': 'Quote updated successfully', 'quote': quote.to_dict()}), 200 + + return jsonify({"message": "Quote updated successfully", "quote": quote.to_dict()}), 200 -@api_v1_bp.route('/quotes/', methods=['DELETE']) -@require_api_token('write:quotes') +@api_v1_bp.route("/quotes/", methods=["DELETE"]) +@require_api_token("write:quotes") def delete_quote(quote_id): """Delete quote --- @@ -2787,14 +2783,15 @@ def delete_quote(quote_id): - Quotes """ from app.models import Quote + quote = Quote.query.get_or_404(quote_id) db.session.delete(quote) db.session.commit() - return jsonify({'message': 'Quote deleted successfully'}), 200 + return jsonify({"message": "Quote deleted successfully"}), 200 -@api_v1_bp.route('/comments/', methods=['PUT', 'PATCH']) -@require_api_token('write:comments') +@api_v1_bp.route("/comments/", methods=["PUT", "PATCH"]) +@require_api_token("write:comments") def update_comment(comment_id): """Update comment --- @@ -2803,20 +2800,20 @@ def update_comment(comment_id): """ cmt = Comment.query.get_or_404(comment_id) if cmt.user_id != g.api_user.id and not g.api_user.is_admin: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 data = request.get_json() or {} - new_content = (data.get('content') or '').strip() + new_content = (data.get("content") or "").strip() if not new_content: - return jsonify({'error': 'content is required'}), 400 + return jsonify({"error": "content is required"}), 400 try: cmt.edit_content(new_content, g.api_user) except PermissionError: - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'message': 'Comment updated successfully', 'comment': cmt.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"message": "Comment updated successfully", "comment": cmt.to_dict()}) -@api_v1_bp.route('/comments/', methods=['DELETE']) -@require_api_token('write:comments') +@api_v1_bp.route("/comments/", methods=["DELETE"]) +@require_api_token("write:comments") def delete_comment(comment_id): """Delete comment --- @@ -2827,82 +2824,86 @@ def delete_comment(comment_id): try: cmt.delete_comment(g.api_user) except PermissionError: - return jsonify({'error': 'Access denied'}), 403 - return jsonify({'message': 'Comment deleted successfully'}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"message": "Comment deleted successfully"}) # ==================== Client Notes ==================== -@api_v1_bp.route('/clients//notes', methods=['GET']) -@require_api_token('read:clients') + +@api_v1_bp.route("/clients//notes", methods=["GET"]) +@require_api_token("read:clients") def list_client_notes(client_id): """List client notes (paginated, important first)""" query = ClientNote.query.filter(ClientNote.client_id == client_id).order_by( ClientNote.is_important.desc(), ClientNote.created_at.desc() ) result = paginate_query(query) - return jsonify({'notes': [n.to_dict() for n in result['items']], 'pagination': result['pagination']}) + return jsonify({"notes": [n.to_dict() for n in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/clients//notes', methods=['POST']) -@require_api_token('write:clients') +@api_v1_bp.route("/clients//notes", methods=["POST"]) +@require_api_token("write:clients") def create_client_note(client_id): """Create client note""" data = request.get_json() or {} - content = (data.get('content') or '').strip() + content = (data.get("content") or "").strip() if not content: - return jsonify({'error': 'content is required'}), 400 - note = ClientNote(content=content, user_id=g.api_user.id, client_id=client_id, is_important=bool(data.get('is_important', False))) + return jsonify({"error": "content is required"}), 400 + note = ClientNote( + content=content, user_id=g.api_user.id, client_id=client_id, is_important=bool(data.get("is_important", False)) + ) db.session.add(note) db.session.commit() - return jsonify({'message': 'Client note created successfully', 'note': note.to_dict()}), 201 + return jsonify({"message": "Client note created successfully", "note": note.to_dict()}), 201 -@api_v1_bp.route('/client-notes/', methods=['GET']) -@require_api_token('read:clients') +@api_v1_bp.route("/client-notes/", methods=["GET"]) +@require_api_token("read:clients") def get_client_note(note_id): note = ClientNote.query.get_or_404(note_id) - return jsonify({'note': note.to_dict()}) + return jsonify({"note": note.to_dict()}) -@api_v1_bp.route('/client-notes/', methods=['PUT', 'PATCH']) -@require_api_token('write:clients') +@api_v1_bp.route("/client-notes/", methods=["PUT", "PATCH"]) +@require_api_token("write:clients") def update_client_note(note_id): note = ClientNote.query.get_or_404(note_id) data = request.get_json() or {} - new_content = (data.get('content') or '').strip() + new_content = (data.get("content") or "").strip() if not new_content: - return jsonify({'error': 'content is required'}), 400 + return jsonify({"error": "content is required"}), 400 if not (g.api_user.is_admin or note.user_id == g.api_user.id): - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 note.content = new_content - if 'is_important' in data: - note.is_important = bool(data['is_important']) + if "is_important" in data: + note.is_important = bool(data["is_important"]) db.session.commit() - return jsonify({'message': 'Client note updated successfully', 'note': note.to_dict()}) + return jsonify({"message": "Client note updated successfully", "note": note.to_dict()}) -@api_v1_bp.route('/client-notes/', methods=['DELETE']) -@require_api_token('write:clients') +@api_v1_bp.route("/client-notes/", methods=["DELETE"]) +@require_api_token("write:clients") def delete_client_note(note_id): note = ClientNote.query.get_or_404(note_id) if not (g.api_user.is_admin or note.user_id == g.api_user.id): - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 db.session.delete(note) db.session.commit() - return jsonify({'message': 'Client note deleted successfully'}) + return jsonify({"message": "Client note deleted successfully"}) # ==================== Project Costs ==================== -@api_v1_bp.route('/projects//costs', methods=['GET']) -@require_api_token('read:projects') + +@api_v1_bp.route("/projects//costs", methods=["GET"]) +@require_api_token("read:projects") def list_project_costs(project_id): """List project costs (paginated)""" - start_date = _parse_date(request.args.get('start_date')) - end_date = _parse_date(request.args.get('end_date')) - user_id = request.args.get('user_id', type=int) - billable_only = (request.args.get('billable_only', 'false').lower() == 'true') + start_date = _parse_date(request.args.get("start_date")) + end_date = _parse_date(request.args.get("end_date")) + user_id = request.args.get("user_id", type=int) + billable_only = request.args.get("billable_only", "false").lower() == "true" query = ProjectCost.query.filter(ProjectCost.project_id == project_id) if start_date: query = query.filter(ProjectCost.cost_date >= start_date) @@ -2914,214 +2915,254 @@ def list_project_costs(project_id): query = query.filter(ProjectCost.billable == True) query = query.order_by(ProjectCost.cost_date.desc(), ProjectCost.created_at.desc()) result = paginate_query(query) - return jsonify({'costs': [c.to_dict() for c in result['items']], 'pagination': result['pagination']}) + return jsonify({"costs": [c.to_dict() for c in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/projects//costs', methods=['POST']) -@require_api_token('write:projects') +@api_v1_bp.route("/projects//costs", methods=["POST"]) +@require_api_token("write:projects") def create_project_cost(project_id): """Create project cost""" data = request.get_json() or {} - required = ['description', 'category', 'amount', 'cost_date'] + required = ["description", "category", "amount", "cost_date"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 from decimal import Decimal + try: - amount = Decimal(str(data['amount'])) + amount = Decimal(str(data["amount"])) except Exception: - return jsonify({'error': 'Invalid amount'}), 400 - cost_date = _parse_date(data.get('cost_date')) + return jsonify({"error": "Invalid amount"}), 400 + cost_date = _parse_date(data.get("cost_date")) if not cost_date: - return jsonify({'error': 'Invalid cost_date'}), 400 + return jsonify({"error": "Invalid cost_date"}), 400 cost = ProjectCost( project_id=project_id, user_id=g.api_user.id, - description=data['description'], - category=data['category'], + description=data["description"], + category=data["category"], amount=amount, cost_date=cost_date, - billable=bool(data.get('billable', True)), - notes=data.get('notes'), - currency_code=data.get('currency_code', 'EUR'), + billable=bool(data.get("billable", True)), + notes=data.get("notes"), + currency_code=data.get("currency_code", "EUR"), ) db.session.add(cost) db.session.commit() - return jsonify({'message': 'Project cost created successfully', 'cost': cost.to_dict()}), 201 + return jsonify({"message": "Project cost created successfully", "cost": cost.to_dict()}), 201 -@api_v1_bp.route('/project-costs/', methods=['GET']) -@require_api_token('read:projects') +@api_v1_bp.route("/project-costs/", methods=["GET"]) +@require_api_token("read:projects") def get_project_cost(cost_id): cost = ProjectCost.query.get_or_404(cost_id) - return jsonify({'cost': cost.to_dict()}) + return jsonify({"cost": cost.to_dict()}) -@api_v1_bp.route('/project-costs/', methods=['PUT', 'PATCH']) -@require_api_token('write:projects') +@api_v1_bp.route("/project-costs/", methods=["PUT", "PATCH"]) +@require_api_token("write:projects") def update_project_cost(cost_id): cost = ProjectCost.query.get_or_404(cost_id) data = request.get_json() or {} - for field in ('description', 'category', 'currency_code', 'notes', 'billable'): + for field in ("description", "category", "currency_code", "notes", "billable"): if field in data: setattr(cost, field, data[field]) - if 'amount' in data: + if "amount" in data: try: from decimal import Decimal - cost.amount = Decimal(str(data['amount'])) + + cost.amount = Decimal(str(data["amount"])) except Exception: pass - if 'cost_date' in data: - parsed = _parse_date(data['cost_date']) + if "cost_date" in data: + parsed = _parse_date(data["cost_date"]) if parsed: cost.cost_date = parsed db.session.commit() - return jsonify({'message': 'Project cost updated successfully', 'cost': cost.to_dict()}) + return jsonify({"message": "Project cost updated successfully", "cost": cost.to_dict()}) -@api_v1_bp.route('/project-costs/', methods=['DELETE']) -@require_api_token('write:projects') +@api_v1_bp.route("/project-costs/", methods=["DELETE"]) +@require_api_token("write:projects") def delete_project_cost(cost_id): cost = ProjectCost.query.get_or_404(cost_id) db.session.delete(cost) db.session.commit() - return jsonify({'message': 'Project cost deleted successfully'}) + return jsonify({"message": "Project cost deleted successfully"}) # ==================== Tax Rules (Admin) ==================== -@api_v1_bp.route('/tax-rules', methods=['GET']) -@require_api_token('admin:all') + +@api_v1_bp.route("/tax-rules", methods=["GET"]) +@require_api_token("admin:all") def list_tax_rules(): """List tax rules (admin)""" rules = TaxRule.query.order_by(TaxRule.created_at.desc()).all() - return jsonify({'tax_rules': [{ - 'id': r.id, - 'name': r.name, - 'country': r.country, - 'region': r.region, - 'client_id': r.client_id, - 'project_id': r.project_id, - 'tax_code': r.tax_code, - 'rate_percent': float(r.rate_percent), - 'compound': r.compound, - 'inclusive': r.inclusive, - 'start_date': r.start_date.isoformat() if r.start_date else None, - 'end_date': r.end_date.isoformat() if r.end_date else None, - 'active': r.active, - 'created_at': r.created_at.isoformat() if r.created_at else None, - } for r in rules]}) - - -@api_v1_bp.route('/tax-rules', methods=['POST']) -@require_api_token('admin:all') + return jsonify( + { + "tax_rules": [ + { + "id": r.id, + "name": r.name, + "country": r.country, + "region": r.region, + "client_id": r.client_id, + "project_id": r.project_id, + "tax_code": r.tax_code, + "rate_percent": float(r.rate_percent), + "compound": r.compound, + "inclusive": r.inclusive, + "start_date": r.start_date.isoformat() if r.start_date else None, + "end_date": r.end_date.isoformat() if r.end_date else None, + "active": r.active, + "created_at": r.created_at.isoformat() if r.created_at else None, + } + for r in rules + ] + } + ) + + +@api_v1_bp.route("/tax-rules", methods=["POST"]) +@require_api_token("admin:all") def create_tax_rule(): data = request.get_json() or {} - required = ['name', 'rate_percent'] + required = ["name", "rate_percent"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 from decimal import Decimal + try: - rate = Decimal(str(data['rate_percent'])) + rate = Decimal(str(data["rate_percent"])) except Exception: - return jsonify({'error': 'Invalid rate_percent'}), 400 + return jsonify({"error": "Invalid rate_percent"}), 400 rule = TaxRule( - name=data['name'], - country=data.get('country'), - region=data.get('region'), - client_id=data.get('client_id'), - project_id=data.get('project_id'), - tax_code=data.get('tax_code'), + name=data["name"], + country=data.get("country"), + region=data.get("region"), + client_id=data.get("client_id"), + project_id=data.get("project_id"), + tax_code=data.get("tax_code"), rate_percent=rate, - compound=bool(data.get('compound', False)), - inclusive=bool(data.get('inclusive', False)), - start_date=_parse_date(data.get('start_date')), - end_date=_parse_date(data.get('end_date')), - active=bool(data.get('active', True)), + compound=bool(data.get("compound", False)), + inclusive=bool(data.get("inclusive", False)), + start_date=_parse_date(data.get("start_date")), + end_date=_parse_date(data.get("end_date")), + active=bool(data.get("active", True)), ) db.session.add(rule) db.session.commit() - return jsonify({'message': 'Tax rule created successfully', 'tax_rule': {'id': rule.id}}), 201 + return jsonify({"message": "Tax rule created successfully", "tax_rule": {"id": rule.id}}), 201 -@api_v1_bp.route('/tax-rules/', methods=['PUT', 'PATCH']) -@require_api_token('admin:all') +@api_v1_bp.route("/tax-rules/", methods=["PUT", "PATCH"]) +@require_api_token("admin:all") def update_tax_rule(rule_id): rule = TaxRule.query.get_or_404(rule_id) data = request.get_json() or {} - for field in ('name', 'country', 'region', 'client_id', 'project_id', 'tax_code', 'compound', 'inclusive', 'active'): + for field in ( + "name", + "country", + "region", + "client_id", + "project_id", + "tax_code", + "compound", + "inclusive", + "active", + ): if field in data: setattr(rule, field, data[field]) - if 'rate_percent' in data: + if "rate_percent" in data: try: from decimal import Decimal - rule.rate_percent = Decimal(str(data['rate_percent'])) + + rule.rate_percent = Decimal(str(data["rate_percent"])) except Exception: pass - if 'start_date' in data: - rule.start_date = _parse_date(data['start_date']) - if 'end_date' in data: - rule.end_date = _parse_date(data['end_date']) + if "start_date" in data: + rule.start_date = _parse_date(data["start_date"]) + if "end_date" in data: + rule.end_date = _parse_date(data["end_date"]) db.session.commit() - return jsonify({'message': 'Tax rule updated successfully'}) + return jsonify({"message": "Tax rule updated successfully"}) -@api_v1_bp.route('/tax-rules/', methods=['DELETE']) -@require_api_token('admin:all') +@api_v1_bp.route("/tax-rules/", methods=["DELETE"]) +@require_api_token("admin:all") def delete_tax_rule(rule_id): rule = TaxRule.query.get_or_404(rule_id) db.session.delete(rule) db.session.commit() - return jsonify({'message': 'Tax rule deleted successfully'}) + return jsonify({"message": "Tax rule deleted successfully"}) # ==================== Currencies & Exchange Rates ==================== -@api_v1_bp.route('/currencies', methods=['GET']) -@require_api_token('read:invoices') + +@api_v1_bp.route("/currencies", methods=["GET"]) +@require_api_token("read:invoices") def list_currencies(): cur_list = Currency.query.order_by(Currency.code.asc()).all() - return jsonify({'currencies': [{ - 'code': c.code, 'name': c.name, 'symbol': c.symbol, 'decimal_places': c.decimal_places, 'is_active': c.is_active - } for c in cur_list]}) + return jsonify( + { + "currencies": [ + { + "code": c.code, + "name": c.name, + "symbol": c.symbol, + "decimal_places": c.decimal_places, + "is_active": c.is_active, + } + for c in cur_list + ] + } + ) -@api_v1_bp.route('/currencies', methods=['POST']) -@require_api_token('admin:all') +@api_v1_bp.route("/currencies", methods=["POST"]) +@require_api_token("admin:all") def create_currency(): data = request.get_json() or {} - required = ['code', 'name'] + required = ["code", "name"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - code = data['code'].upper().strip() + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + code = data["code"].upper().strip() if Currency.query.get(code): - return jsonify({'error': 'Currency already exists'}), 400 - cur = Currency(code=code, name=data['name'], symbol=data.get('symbol'), decimal_places=int(data.get('decimal_places', 2)), is_active=bool(data.get('is_active', True))) + return jsonify({"error": "Currency already exists"}), 400 + cur = Currency( + code=code, + name=data["name"], + symbol=data.get("symbol"), + decimal_places=int(data.get("decimal_places", 2)), + is_active=bool(data.get("is_active", True)), + ) db.session.add(cur) db.session.commit() - return jsonify({'message': 'Currency created successfully', 'currency': {'code': cur.code}}), 201 + return jsonify({"message": "Currency created successfully", "currency": {"code": cur.code}}), 201 -@api_v1_bp.route('/currencies/', methods=['PUT', 'PATCH']) -@require_api_token('admin:all') +@api_v1_bp.route("/currencies/", methods=["PUT", "PATCH"]) +@require_api_token("admin:all") def update_currency(code): cur = Currency.query.get_or_404(code.upper()) data = request.get_json() or {} - for field in ('name', 'symbol', 'decimal_places', 'is_active'): + for field in ("name", "symbol", "decimal_places", "is_active"): if field in data: setattr(cur, field, data[field]) db.session.commit() - return jsonify({'message': 'Currency updated successfully'}) + return jsonify({"message": "Currency updated successfully"}) -@api_v1_bp.route('/exchange-rates', methods=['GET']) -@require_api_token('read:invoices') +@api_v1_bp.route("/exchange-rates", methods=["GET"]) +@require_api_token("read:invoices") def list_exchange_rates(): - base = request.args.get('base_code') - quote = request.args.get('quote_code') - date_str = request.args.get('date') + base = request.args.get("base_code") + quote = request.args.get("quote_code") + date_str = request.args.get("date") q = ExchangeRate.query if base: q = q.filter(ExchangeRate.base_code == base.upper()) @@ -3132,105 +3173,121 @@ def list_exchange_rates(): if d: q = q.filter(ExchangeRate.date == d) rates = q.order_by(ExchangeRate.date.desc()).limit(200).all() - return jsonify({'exchange_rates': [{ - 'id': r.id, 'base_code': r.base_code, 'quote_code': r.quote_code, 'rate': float(r.rate), 'date': r.date.isoformat(), 'source': r.source - } for r in rates]}) + return jsonify( + { + "exchange_rates": [ + { + "id": r.id, + "base_code": r.base_code, + "quote_code": r.quote_code, + "rate": float(r.rate), + "date": r.date.isoformat(), + "source": r.source, + } + for r in rates + ] + } + ) -@api_v1_bp.route('/exchange-rates', methods=['POST']) -@require_api_token('admin:all') +@api_v1_bp.route("/exchange-rates", methods=["POST"]) +@require_api_token("admin:all") def create_exchange_rate(): data = request.get_json() or {} - required = ['base_code', 'quote_code', 'rate', 'date'] + required = ["base_code", "quote_code", "rate", "date"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 from decimal import Decimal + try: - rate_val = Decimal(str(data['rate'])) + rate_val = Decimal(str(data["rate"])) except Exception: - return jsonify({'error': 'Invalid rate'}), 400 - d = _parse_date(data['date']) + return jsonify({"error": "Invalid rate"}), 400 + d = _parse_date(data["date"]) if not d: - return jsonify({'error': 'Invalid date'}), 400 + return jsonify({"error": "Invalid date"}), 400 er = ExchangeRate( - base_code=data['base_code'].upper(), - quote_code=data['quote_code'].upper(), + base_code=data["base_code"].upper(), + quote_code=data["quote_code"].upper(), rate=rate_val, date=d, - source=data.get('source'), + source=data.get("source"), ) db.session.add(er) db.session.commit() - return jsonify({'message': 'Exchange rate created successfully', 'exchange_rate': {'id': er.id}}), 201 + return jsonify({"message": "Exchange rate created successfully", "exchange_rate": {"id": er.id}}), 201 -@api_v1_bp.route('/exchange-rates/', methods=['PUT', 'PATCH']) -@require_api_token('admin:all') +@api_v1_bp.route("/exchange-rates/", methods=["PUT", "PATCH"]) +@require_api_token("admin:all") def update_exchange_rate(rate_id): er = ExchangeRate.query.get_or_404(rate_id) data = request.get_json() or {} - if 'rate' in data: + if "rate" in data: try: from decimal import Decimal - er.rate = Decimal(str(data['rate'])) + + er.rate = Decimal(str(data["rate"])) except Exception: pass - if 'date' in data: - d = _parse_date(data['date']) + if "date" in data: + d = _parse_date(data["date"]) if d: er.date = d - if 'source' in data: - er.source = data['source'] + if "source" in data: + er.source = data["source"] db.session.commit() - return jsonify({'message': 'Exchange rate updated successfully'}) + return jsonify({"message": "Exchange rate updated successfully"}) # ==================== Favorites ==================== -@api_v1_bp.route('/users/me/favorites/projects', methods=['GET']) -@require_api_token('read:projects') + +@api_v1_bp.route("/users/me/favorites/projects", methods=["GET"]) +@require_api_token("read:projects") def list_favorite_projects(): favs = UserFavoriteProject.query.filter_by(user_id=g.api_user.id).all() - return jsonify({'favorites': [f.to_dict() for f in favs]}) + return jsonify({"favorites": [f.to_dict() for f in favs]}) -@api_v1_bp.route('/users/me/favorites/projects', methods=['POST']) -@require_api_token('write:projects') +@api_v1_bp.route("/users/me/favorites/projects", methods=["POST"]) +@require_api_token("write:projects") def add_favorite_project(): data = request.get_json() or {} - project_id = data.get('project_id') + project_id = data.get("project_id") if not project_id: - return jsonify({'error': 'project_id is required'}), 400 + return jsonify({"error": "project_id is required"}), 400 # Prevent duplicates due to unique constraint existing = UserFavoriteProject.query.filter_by(user_id=g.api_user.id, project_id=project_id).first() if existing: - return jsonify({'message': 'Already favorited', 'favorite': existing.to_dict()}), 200 + return jsonify({"message": "Already favorited", "favorite": existing.to_dict()}), 200 fav = UserFavoriteProject(user_id=g.api_user.id, project_id=project_id) db.session.add(fav) db.session.commit() - return jsonify({'message': 'Project favorited successfully', 'favorite': fav.to_dict()}), 201 + return jsonify({"message": "Project favorited successfully", "favorite": fav.to_dict()}), 201 -@api_v1_bp.route('/users/me/favorites/projects/', methods=['DELETE']) -@require_api_token('write:projects') +@api_v1_bp.route("/users/me/favorites/projects/", methods=["DELETE"]) +@require_api_token("write:projects") def remove_favorite_project(project_id): fav = UserFavoriteProject.query.filter_by(user_id=g.api_user.id, project_id=project_id).first_or_404() db.session.delete(fav) db.session.commit() - return jsonify({'message': 'Favorite removed successfully'}) + return jsonify({"message": "Favorite removed successfully"}) # ==================== Audit Logs (Admin) ==================== -@api_v1_bp.route('/audit-logs', methods=['GET']) -@require_api_token('admin:all') + +@api_v1_bp.route("/audit-logs", methods=["GET"]) +@require_api_token("admin:all") def list_audit_logs(): """List audit logs (admin)""" - entity_type = request.args.get('entity_type') - user_id = request.args.get('user_id', type=int) - action = request.args.get('action') - limit = request.args.get('limit', type=int) or 100 + entity_type = request.args.get("entity_type") + user_id = request.args.get("user_id", type=int) + action = request.args.get("action") + limit = request.args.get("limit", type=int) or 100 q = AuditLog.query if entity_type: q = q.filter(AuditLog.entity_type == entity_type) @@ -3239,136 +3296,152 @@ def list_audit_logs(): if action: q = q.filter(AuditLog.action == action) logs = q.order_by(AuditLog.created_at.desc()).limit(limit).all() - return jsonify({'audit_logs': [l.to_dict() for l in logs]}) + return jsonify({"audit_logs": [l.to_dict() for l in logs]}) # ==================== Activities ==================== -@api_v1_bp.route('/activities', methods=['GET']) -@require_api_token('read:reports') + +@api_v1_bp.route("/activities", methods=["GET"]) +@require_api_token("read:reports") def list_activities(): """List activities""" - user_id = request.args.get('user_id', type=int) - entity_type = request.args.get('entity_type') - limit = request.args.get('limit', type=int) or 50 + user_id = request.args.get("user_id", type=int) + entity_type = request.args.get("entity_type") + limit = request.args.get("limit", type=int) or 50 acts = Activity.get_recent(user_id=user_id, limit=limit, entity_type=entity_type) - return jsonify({'activities': [a.to_dict() for a in acts]}) + return jsonify({"activities": [a.to_dict() for a in acts]}) # ==================== Invoice PDF Templates (Admin) ==================== -@api_v1_bp.route('/invoice-pdf-templates', methods=['GET']) -@require_api_token('admin:all') + +@api_v1_bp.route("/invoice-pdf-templates", methods=["GET"]) +@require_api_token("admin:all") def list_invoice_pdf_templates(): templates = InvoicePDFTemplate.get_all_templates() - return jsonify({'templates': [t.to_dict() for t in templates]}) + return jsonify({"templates": [t.to_dict() for t in templates]}) -@api_v1_bp.route('/invoice-pdf-templates/', methods=['GET']) -@require_api_token('admin:all') +@api_v1_bp.route("/invoice-pdf-templates/", methods=["GET"]) +@require_api_token("admin:all") def get_invoice_pdf_template(page_size): tpl = InvoicePDFTemplate.get_template(page_size) - return jsonify({'template': tpl.to_dict()}) + return jsonify({"template": tpl.to_dict()}) # ==================== Invoice Templates (Admin) ==================== -@api_v1_bp.route('/invoice-templates', methods=['GET']) -@require_api_token('admin:all') + +@api_v1_bp.route("/invoice-templates", methods=["GET"]) +@require_api_token("admin:all") def list_invoice_templates(): """List invoice templates (admin)""" templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name.asc()).all() - return jsonify({'templates': [{ - 'id': t.id, - 'name': t.name, - 'description': t.description, - 'html': t.html or '', - 'css': t.css or '', - 'is_default': t.is_default, - 'created_at': t.created_at.isoformat() if t.created_at else None, - 'updated_at': t.updated_at.isoformat() if t.updated_at else None, - } for t in templates]}) - - -@api_v1_bp.route('/invoice-templates/', methods=['GET']) -@require_api_token('admin:all') + return jsonify( + { + "templates": [ + { + "id": t.id, + "name": t.name, + "description": t.description, + "html": t.html or "", + "css": t.css or "", + "is_default": t.is_default, + "created_at": t.created_at.isoformat() if t.created_at else None, + "updated_at": t.updated_at.isoformat() if t.updated_at else None, + } + for t in templates + ] + } + ) + + +@api_v1_bp.route("/invoice-templates/", methods=["GET"]) +@require_api_token("admin:all") def get_invoice_template(template_id): t = InvoiceTemplate.query.get_or_404(template_id) - return jsonify({'template': { - 'id': t.id, - 'name': t.name, - 'description': t.description, - 'html': t.html or '', - 'css': t.css or '', - 'is_default': t.is_default, - 'created_at': t.created_at.isoformat() if t.created_at else None, - 'updated_at': t.updated_at.isoformat() if t.updated_at else None, - }}) - - -@api_v1_bp.route('/invoice-templates', methods=['POST']) -@require_api_token('admin:all') + return jsonify( + { + "template": { + "id": t.id, + "name": t.name, + "description": t.description, + "html": t.html or "", + "css": t.css or "", + "is_default": t.is_default, + "created_at": t.created_at.isoformat() if t.created_at else None, + "updated_at": t.updated_at.isoformat() if t.updated_at else None, + } + } + ) + + +@api_v1_bp.route("/invoice-templates", methods=["POST"]) +@require_api_token("admin:all") def create_invoice_template(): data = request.get_json() or {} - name = (data.get('name') or '').strip() + name = (data.get("name") or "").strip() if not name: - return jsonify({'error': 'name is required'}), 400 + return jsonify({"error": "name is required"}), 400 # Enforce unique name if InvoiceTemplate.query.filter_by(name=name).first(): - return jsonify({'error': 'Template name already exists'}), 400 - is_default = bool(data.get('is_default', False)) + return jsonify({"error": "Template name already exists"}), 400 + is_default = bool(data.get("is_default", False)) if is_default: InvoiceTemplate.query.update({InvoiceTemplate.is_default: False}) t = InvoiceTemplate( name=name, - description=(data.get('description') or '').strip() or None, - html=(data.get('html') or '').strip() or None, - css=(data.get('css') or '').strip() or None, + description=(data.get("description") or "").strip() or None, + html=(data.get("html") or "").strip() or None, + css=(data.get("css") or "").strip() or None, is_default=is_default, ) db.session.add(t) db.session.commit() - return jsonify({'message': 'Invoice template created successfully', 'template': {'id': t.id}}), 201 + return jsonify({"message": "Invoice template created successfully", "template": {"id": t.id}}), 201 -@api_v1_bp.route('/invoice-templates/', methods=['PUT', 'PATCH']) -@require_api_token('admin:all') +@api_v1_bp.route("/invoice-templates/", methods=["PUT", "PATCH"]) +@require_api_token("admin:all") def update_invoice_template(template_id): t = InvoiceTemplate.query.get_or_404(template_id) data = request.get_json() or {} - if 'name' in data: - name = (data.get('name') or '').strip() + if "name" in data: + name = (data.get("name") or "").strip() if not name: - return jsonify({'error': 'name cannot be empty'}), 400 + return jsonify({"error": "name cannot be empty"}), 400 # Check duplicate name existing = InvoiceTemplate.query.filter(InvoiceTemplate.name == name, InvoiceTemplate.id != template_id).first() if existing: - return jsonify({'error': 'Template name already exists'}), 400 + return jsonify({"error": "Template name already exists"}), 400 t.name = name - for field in ('description', 'html', 'css'): + for field in ("description", "html", "css"): if field in data: - setattr(t, field, (data.get(field) or '').strip() or None) - if 'is_default' in data and bool(data['is_default']): + setattr(t, field, (data.get(field) or "").strip() or None) + if "is_default" in data and bool(data["is_default"]): # set this as default, unset others InvoiceTemplate.query.filter(InvoiceTemplate.id != template_id).update({InvoiceTemplate.is_default: False}) t.is_default = True db.session.commit() - return jsonify({'message': 'Invoice template updated successfully'}) + return jsonify({"message": "Invoice template updated successfully"}) -@api_v1_bp.route('/invoice-templates/', methods=['DELETE']) -@require_api_token('admin:all') +@api_v1_bp.route("/invoice-templates/", methods=["DELETE"]) +@require_api_token("admin:all") def delete_invoice_template(template_id): t = InvoiceTemplate.query.get_or_404(template_id) # In a stricter implementation, we could prevent deletion if referenced db.session.delete(t) db.session.commit() - return jsonify({'message': 'Invoice template deleted successfully'}) + return jsonify({"message": "Invoice template deleted successfully"}) + # ==================== Recurring Invoices ==================== -@api_v1_bp.route('/recurring-invoices', methods=['GET']) -@require_api_token('read:recurring_invoices') + +@api_v1_bp.route("/recurring-invoices", methods=["GET"]) +@require_api_token("read:recurring_invoices") def list_recurring_invoices(): """List recurring invoice templates --- @@ -3376,29 +3449,29 @@ def list_recurring_invoices(): - RecurringInvoices """ query = RecurringInvoice.query - is_active = request.args.get('is_active') + is_active = request.args.get("is_active") if is_active is not None: - query = query.filter(RecurringInvoice.is_active == (is_active.lower() == 'true')) - client_id = request.args.get('client_id', type=int) + query = query.filter(RecurringInvoice.is_active == (is_active.lower() == "true")) + client_id = request.args.get("client_id", type=int) if client_id: query = query.filter(RecurringInvoice.client_id == client_id) - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter(RecurringInvoice.project_id == project_id) result = paginate_query(query.order_by(RecurringInvoice.created_at.desc())) - return jsonify({'recurring_invoices': [ri.to_dict() for ri in result['items']], 'pagination': result['pagination']}) + return jsonify({"recurring_invoices": [ri.to_dict() for ri in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/recurring-invoices/', methods=['GET']) -@require_api_token('read:recurring_invoices') +@api_v1_bp.route("/recurring-invoices/", methods=["GET"]) +@require_api_token("read:recurring_invoices") def get_recurring_invoice(ri_id): """Get a recurring invoice template""" ri = RecurringInvoice.query.get_or_404(ri_id) - return jsonify({'recurring_invoice': ri.to_dict()}) + return jsonify({"recurring_invoice": ri.to_dict()}) -@api_v1_bp.route('/recurring-invoices', methods=['POST']) -@require_api_token('write:recurring_invoices') +@api_v1_bp.route("/recurring-invoices", methods=["POST"]) +@require_api_token("write:recurring_invoices") def create_recurring_invoice(): """Create a recurring invoice template --- @@ -3406,109 +3479,111 @@ def create_recurring_invoice(): - RecurringInvoices """ data = request.get_json() or {} - required = ['name', 'project_id', 'client_id', 'client_name', 'frequency', 'next_run_date'] + required = ["name", "project_id", "client_id", "client_name", "frequency", "next_run_date"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - freq = (data.get('frequency') or '').lower() - if freq not in ('daily', 'weekly', 'monthly', 'yearly'): - return jsonify({'error': 'Invalid frequency'}), 400 - next_date = _parse_date(data.get('next_run_date')) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + freq = (data.get("frequency") or "").lower() + if freq not in ("daily", "weekly", "monthly", "yearly"): + return jsonify({"error": "Invalid frequency"}), 400 + next_date = _parse_date(data.get("next_run_date")) if not next_date: - return jsonify({'error': 'Invalid next_run_date (YYYY-MM-DD)'}), 400 + return jsonify({"error": "Invalid next_run_date (YYYY-MM-DD)"}), 400 ri = RecurringInvoice( - name=data['name'], - project_id=data['project_id'], - client_id=data['client_id'], + name=data["name"], + project_id=data["project_id"], + client_id=data["client_id"], frequency=freq, next_run_date=next_date, created_by=g.api_user.id, - interval=data.get('interval', 1), - end_date=_parse_date(data.get('end_date')), - client_name=data['client_name'], - client_email=data.get('client_email'), - client_address=data.get('client_address'), - due_date_days=data.get('due_date_days', 30), - tax_rate=data.get('tax_rate', 0), - currency_code=data.get('currency_code', 'EUR'), - notes=data.get('notes'), - terms=data.get('terms'), - template_id=data.get('template_id'), - auto_send=bool(data.get('auto_send', False)), - auto_include_time_entries=bool(data.get('auto_include_time_entries', True)), - is_active=bool(data.get('is_active', True)), + interval=data.get("interval", 1), + end_date=_parse_date(data.get("end_date")), + client_name=data["client_name"], + client_email=data.get("client_email"), + client_address=data.get("client_address"), + due_date_days=data.get("due_date_days", 30), + tax_rate=data.get("tax_rate", 0), + currency_code=data.get("currency_code", "EUR"), + notes=data.get("notes"), + terms=data.get("terms"), + template_id=data.get("template_id"), + auto_send=bool(data.get("auto_send", False)), + auto_include_time_entries=bool(data.get("auto_include_time_entries", True)), + is_active=bool(data.get("is_active", True)), ) db.session.add(ri) db.session.commit() - return jsonify({'message': 'Recurring invoice created successfully', 'recurring_invoice': ri.to_dict()}), 201 + return jsonify({"message": "Recurring invoice created successfully", "recurring_invoice": ri.to_dict()}), 201 -@api_v1_bp.route('/recurring-invoices/', methods=['PUT', 'PATCH']) -@require_api_token('write:recurring_invoices') +@api_v1_bp.route("/recurring-invoices/", methods=["PUT", "PATCH"]) +@require_api_token("write:recurring_invoices") def update_recurring_invoice(ri_id): """Update a recurring invoice template""" ri = RecurringInvoice.query.get_or_404(ri_id) data = request.get_json() or {} - for field in ('name', 'client_name', 'client_email', 'client_address', 'notes', 'terms', 'currency_code'): + for field in ("name", "client_name", "client_email", "client_address", "notes", "terms", "currency_code"): if field in data: setattr(ri, field, data[field]) - if 'frequency' in data and data['frequency'] in ('daily', 'weekly', 'monthly', 'yearly'): - ri.frequency = data['frequency'] - if 'interval' in data: + if "frequency" in data and data["frequency"] in ("daily", "weekly", "monthly", "yearly"): + ri.frequency = data["frequency"] + if "interval" in data: try: - ri.interval = int(data['interval']) + ri.interval = int(data["interval"]) except Exception: pass - if 'next_run_date' in data: - parsed = _parse_date(data['next_run_date']) + if "next_run_date" in data: + parsed = _parse_date(data["next_run_date"]) if parsed: ri.next_run_date = parsed - if 'end_date' in data: - ri.end_date = _parse_date(data['end_date']) - for bfield in ('auto_send', 'auto_include_time_entries', 'is_active'): + if "end_date" in data: + ri.end_date = _parse_date(data["end_date"]) + for bfield in ("auto_send", "auto_include_time_entries", "is_active"): if bfield in data: setattr(ri, bfield, bool(data[bfield])) - if 'due_date_days' in data: + if "due_date_days" in data: try: - ri.due_date_days = int(data['due_date_days']) + ri.due_date_days = int(data["due_date_days"]) except Exception: pass - if 'tax_rate' in data: + if "tax_rate" in data: try: from decimal import Decimal - ri.tax_rate = Decimal(str(data['tax_rate'])) + + ri.tax_rate = Decimal(str(data["tax_rate"])) except Exception: pass db.session.commit() - return jsonify({'message': 'Recurring invoice updated successfully', 'recurring_invoice': ri.to_dict()}) + return jsonify({"message": "Recurring invoice updated successfully", "recurring_invoice": ri.to_dict()}) -@api_v1_bp.route('/recurring-invoices/', methods=['DELETE']) -@require_api_token('write:recurring_invoices') +@api_v1_bp.route("/recurring-invoices/", methods=["DELETE"]) +@require_api_token("write:recurring_invoices") def delete_recurring_invoice(ri_id): """Deactivate a recurring invoice template""" ri = RecurringInvoice.query.get_or_404(ri_id) ri.is_active = False db.session.commit() - return jsonify({'message': 'Recurring invoice deactivated successfully'}) + return jsonify({"message": "Recurring invoice deactivated successfully"}) -@api_v1_bp.route('/recurring-invoices//generate', methods=['POST']) -@require_api_token('write:recurring_invoices') +@api_v1_bp.route("/recurring-invoices//generate", methods=["POST"]) +@require_api_token("write:recurring_invoices") def generate_from_recurring_invoice(ri_id): """Generate an invoice from a recurring template""" ri = RecurringInvoice.query.get_or_404(ri_id) invoice = ri.generate_invoice() if not invoice: - return jsonify({'message': 'No invoice generated (not due yet or inactive)'}), 200 + return jsonify({"message": "No invoice generated (not due yet or inactive)"}), 200 db.session.commit() - return jsonify({'message': 'Invoice generated successfully', 'invoice': invoice.to_dict()}), 201 + return jsonify({"message": "Invoice generated successfully", "invoice": invoice.to_dict()}), 201 # ==================== Credit Notes ==================== -@api_v1_bp.route('/credit-notes', methods=['GET']) -@require_api_token('read:invoices') + +@api_v1_bp.route("/credit-notes", methods=["GET"]) +@require_api_token("read:invoices") def list_credit_notes(): """List credit notes --- @@ -3516,107 +3591,131 @@ def list_credit_notes(): - CreditNotes """ query = CreditNote.query - invoice_id = request.args.get('invoice_id', type=int) + invoice_id = request.args.get("invoice_id", type=int) if invoice_id: query = query.filter(CreditNote.invoice_id == invoice_id) result = paginate_query(query.order_by(CreditNote.created_at.desc())) - return jsonify({'credit_notes': [{ - 'id': cn.id, - 'invoice_id': cn.invoice_id, - 'credit_number': cn.credit_number, - 'amount': float(cn.amount), - 'reason': cn.reason, - 'created_by': cn.created_by, - 'created_at': cn.created_at.isoformat() if cn.created_at else None - } for cn in result['items']], 'pagination': result['pagination']}) - - -@api_v1_bp.route('/credit-notes/', methods=['GET']) -@require_api_token('read:invoices') + return jsonify( + { + "credit_notes": [ + { + "id": cn.id, + "invoice_id": cn.invoice_id, + "credit_number": cn.credit_number, + "amount": float(cn.amount), + "reason": cn.reason, + "created_by": cn.created_by, + "created_at": cn.created_at.isoformat() if cn.created_at else None, + } + for cn in result["items"] + ], + "pagination": result["pagination"], + } + ) + + +@api_v1_bp.route("/credit-notes/", methods=["GET"]) +@require_api_token("read:invoices") def get_credit_note(cn_id): """Get credit note""" cn = CreditNote.query.get_or_404(cn_id) - return jsonify({'credit_note': { - 'id': cn.id, - 'invoice_id': cn.invoice_id, - 'credit_number': cn.credit_number, - 'amount': float(cn.amount), - 'reason': cn.reason, - 'created_by': cn.created_by, - 'created_at': cn.created_at.isoformat() if cn.created_at else None - }}) - - -@api_v1_bp.route('/credit-notes', methods=['POST']) -@require_api_token('write:invoices') + return jsonify( + { + "credit_note": { + "id": cn.id, + "invoice_id": cn.invoice_id, + "credit_number": cn.credit_number, + "amount": float(cn.amount), + "reason": cn.reason, + "created_by": cn.created_by, + "created_at": cn.created_at.isoformat() if cn.created_at else None, + } + } + ) + + +@api_v1_bp.route("/credit-notes", methods=["POST"]) +@require_api_token("write:invoices") def create_credit_note(): """Create credit note""" data = request.get_json() or {} - required = ['invoice_id', 'amount'] + required = ["invoice_id", "amount"] missing = [f for f in required if not data.get(f)] if missing: - return jsonify({'error': f"Missing required fields: {', '.join(missing)}"}), 400 - inv = Invoice.query.get(data['invoice_id']) + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + inv = Invoice.query.get(data["invoice_id"]) if not inv: - return jsonify({'error': 'Invalid invoice_id'}), 400 + return jsonify({"error": "Invalid invoice_id"}), 400 from decimal import Decimal + try: - amt = Decimal(str(data['amount'])) + amt = Decimal(str(data["amount"])) except Exception: - return jsonify({'error': 'Invalid amount'}), 400 + return jsonify({"error": "Invalid amount"}), 400 # Generate credit number (simple: CN--) credit_number = f"CN-{inv.id}-{int(datetime.utcnow().timestamp())}" cn = CreditNote( invoice_id=inv.id, credit_number=credit_number, amount=amt, - reason=data.get('reason'), + reason=data.get("reason"), created_by=g.api_user.id, ) db.session.add(cn) db.session.commit() - return jsonify({'message': 'Credit note created successfully', 'credit_note': { - 'id': cn.id, - 'invoice_id': cn.invoice_id, - 'credit_number': cn.credit_number, - 'amount': float(cn.amount), - 'reason': cn.reason, - 'created_by': cn.created_by, - 'created_at': cn.created_at.isoformat() if cn.created_at else None - }}), 201 - - -@api_v1_bp.route('/credit-notes/', methods=['PUT', 'PATCH']) -@require_api_token('write:invoices') + return ( + jsonify( + { + "message": "Credit note created successfully", + "credit_note": { + "id": cn.id, + "invoice_id": cn.invoice_id, + "credit_number": cn.credit_number, + "amount": float(cn.amount), + "reason": cn.reason, + "created_by": cn.created_by, + "created_at": cn.created_at.isoformat() if cn.created_at else None, + }, + } + ), + 201, + ) + + +@api_v1_bp.route("/credit-notes/", methods=["PUT", "PATCH"]) +@require_api_token("write:invoices") def update_credit_note(cn_id): """Update credit note""" cn = CreditNote.query.get_or_404(cn_id) data = request.get_json() or {} - if 'reason' in data: - cn.reason = data['reason'] - if 'amount' in data: + if "reason" in data: + cn.reason = data["reason"] + if "amount" in data: try: from decimal import Decimal - cn.amount = Decimal(str(data['amount'])) + + cn.amount = Decimal(str(data["amount"])) except Exception: pass db.session.commit() - return jsonify({'message': 'Credit note updated successfully'}) + return jsonify({"message": "Credit note updated successfully"}) -@api_v1_bp.route('/credit-notes/', methods=['DELETE']) -@require_api_token('write:invoices') +@api_v1_bp.route("/credit-notes/", methods=["DELETE"]) +@require_api_token("write:invoices") def delete_credit_note(cn_id): """Delete credit note""" cn = CreditNote.query.get_or_404(cn_id) db.session.delete(cn) db.session.commit() - return jsonify({'message': 'Credit note deleted successfully'}) + return jsonify({"message": "Credit note deleted successfully"}) + # ==================== Reports ==================== -@api_v1_bp.route('/reports/summary', methods=['GET']) -@require_api_token('read:reports') + +@api_v1_bp.route("/reports/summary", methods=["GET"]) +@require_api_token("read:reports") def report_summary(): """Get time tracking summary report --- @@ -3644,78 +3743,79 @@ def report_summary(): description: Summary report """ # Date range (default to last 30 days) - end_date = request.args.get('end_date') - start_date = request.args.get('start_date') - + end_date = request.args.get("end_date") + start_date = request.args.get("start_date") + if not end_date: end_dt = datetime.utcnow() else: end_dt = parse_datetime(end_date) or datetime.utcnow() - + if not start_date: start_dt = end_dt - timedelta(days=30) else: start_dt = parse_datetime(start_date) or (end_dt - timedelta(days=30)) - + # Build query query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt ) - + # Filter by user - user_id = request.args.get('user_id', type=int) + user_id = request.args.get("user_id", type=int) if user_id: if g.api_user.is_admin or user_id == g.api_user.id: query = query.filter_by(user_id=user_id) else: - return jsonify({'error': 'Access denied'}), 403 + return jsonify({"error": "Access denied"}), 403 elif not g.api_user.is_admin: query = query.filter_by(user_id=g.api_user.id) - + # Filter by project - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) if project_id: query = query.filter_by(project_id=project_id) - + entries = query.all() - + # Calculate summary total_hours = sum(e.duration_hours or 0 for e in entries) billable_hours = sum(e.duration_hours or 0 for e in entries if e.billable) total_entries = len(entries) - + # Group by project by_project = {} for entry in entries: if entry.project_id: if entry.project_id not in by_project: by_project[entry.project_id] = { - 'project_id': entry.project_id, - 'project_name': entry.project.name if entry.project else 'Unknown', - 'hours': 0, - 'entries': 0 + "project_id": entry.project_id, + "project_name": entry.project.name if entry.project else "Unknown", + "hours": 0, + "entries": 0, } - by_project[entry.project_id]['hours'] += entry.duration_hours or 0 - by_project[entry.project_id]['entries'] += 1 - - return jsonify({ - 'summary': { - 'start_date': start_dt.isoformat(), - 'end_date': end_dt.isoformat(), - 'total_hours': round(total_hours, 2), - 'billable_hours': round(billable_hours, 2), - 'total_entries': total_entries, - 'by_project': list(by_project.values()) + by_project[entry.project_id]["hours"] += entry.duration_hours or 0 + by_project[entry.project_id]["entries"] += 1 + + return jsonify( + { + "summary": { + "start_date": start_dt.isoformat(), + "end_date": end_dt.isoformat(), + "total_hours": round(total_hours, 2), + "billable_hours": round(billable_hours, 2), + "total_entries": total_entries, + "by_project": list(by_project.values()), + } } - }) + ) # ==================== Users ==================== -@api_v1_bp.route('/users/me', methods=['GET']) -@require_api_token('read:users') + +@api_v1_bp.route("/users/me", methods=["GET"]) +@require_api_token("read:users") def get_current_user(): """Get current authenticated user information --- @@ -3727,11 +3827,11 @@ def get_current_user(): 200: description: Current user information """ - return jsonify({'user': g.api_user.to_dict()}) + return jsonify({"user": g.api_user.to_dict()}) -@api_v1_bp.route('/users', methods=['GET']) -@require_api_token('admin:all') +@api_v1_bp.route("/users", methods=["GET"]) +@require_api_token("admin:all") def list_users(): """List all users (admin only) --- @@ -3751,20 +3851,18 @@ def list_users(): description: List of users """ query = User.query.filter_by(is_active=True).order_by(User.username) - + # Paginate result = paginate_query(query) - - return jsonify({ - 'users': [u.to_dict() for u in result['items']], - 'pagination': result['pagination'] - }) + + return jsonify({"users": [u.to_dict() for u in result["items"]], "pagination": result["pagination"]}) # ==================== Webhooks ==================== -@api_v1_bp.route('/webhooks', methods=['GET']) -@require_api_token('read:webhooks') + +@api_v1_bp.route("/webhooks", methods=["GET"]) +@require_api_token("read:webhooks") def list_webhooks(): """List all webhooks --- @@ -3787,29 +3885,26 @@ def list_webhooks(): description: List of webhooks """ query = Webhook.query - + # Filter by active status - is_active = request.args.get('is_active') + is_active = request.args.get("is_active") if is_active is not None: - query = query.filter_by(is_active=is_active.lower() == 'true') - + query = query.filter_by(is_active=is_active.lower() == "true") + # Filter by user (non-admins can only see their own) if not g.api_user.is_admin: query = query.filter_by(user_id=g.api_user.id) - + query = query.order_by(Webhook.created_at.desc()) - + # Paginate result = paginate_query(query) - - return jsonify({ - 'webhooks': [w.to_dict() for w in result['items']], - 'pagination': result['pagination'] - }) + + return jsonify({"webhooks": [w.to_dict() for w in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/webhooks', methods=['POST']) -@require_api_token('write:webhooks') +@api_v1_bp.route("/webhooks", methods=["POST"]) +@require_api_token("write:webhooks") def create_webhook(): """Create a new webhook --- @@ -3824,64 +3919,63 @@ def create_webhook(): description: Invalid input """ data = request.get_json() or {} - + # Validate required fields - if not data.get('name'): - return jsonify({'error': 'name is required'}), 400 - if not data.get('url'): - return jsonify({'error': 'url is required'}), 400 - if not data.get('events') or not isinstance(data.get('events'), list): - return jsonify({'error': 'events must be a non-empty list'}), 400 - + if not data.get("name"): + return jsonify({"error": "name is required"}), 400 + if not data.get("url"): + return jsonify({"error": "url is required"}), 400 + if not data.get("events") or not isinstance(data.get("events"), list): + return jsonify({"error": "events must be a non-empty list"}), 400 + # Validate URL try: from urllib.parse import urlparse - parsed = urlparse(data['url']) + + parsed = urlparse(data["url"]) if not parsed.scheme or not parsed.netloc: - return jsonify({'error': 'Invalid URL format'}), 400 - if parsed.scheme not in ['http', 'https']: - return jsonify({'error': 'URL must use http or https'}), 400 + return jsonify({"error": "Invalid URL format"}), 400 + if parsed.scheme not in ["http", "https"]: + return jsonify({"error": "URL must use http or https"}), 400 except Exception: - return jsonify({'error': 'Invalid URL format'}), 400 - + return jsonify({"error": "Invalid URL format"}), 400 + # Validate events from app.utils.webhook_service import WebhookService + available_events = WebhookService.get_available_events() - for event in data['events']: - if event != '*' and event not in available_events: - return jsonify({'error': f'Invalid event type: {event}'}), 400 - + for event in data["events"]: + if event != "*" and event not in available_events: + return jsonify({"error": f"Invalid event type: {event}"}), 400 + # Create webhook webhook = Webhook( - name=data['name'], - description=data.get('description'), - url=data['url'], - events=data['events'], - http_method=data.get('http_method', 'POST'), - content_type=data.get('content_type', 'application/json'), - headers=data.get('headers'), - is_active=data.get('is_active', True), + name=data["name"], + description=data.get("description"), + url=data["url"], + events=data["events"], + http_method=data.get("http_method", "POST"), + content_type=data.get("content_type", "application/json"), + headers=data.get("headers"), + is_active=data.get("is_active", True), user_id=g.api_user.id, - max_retries=data.get('max_retries', 3), - retry_delay_seconds=data.get('retry_delay_seconds', 60), - timeout_seconds=data.get('timeout_seconds', 30), + max_retries=data.get("max_retries", 3), + retry_delay_seconds=data.get("retry_delay_seconds", 60), + timeout_seconds=data.get("timeout_seconds", 30), ) - + # Generate secret if requested - if data.get('generate_secret', True): + if data.get("generate_secret", True): webhook.set_secret() - + db.session.add(webhook) db.session.commit() - - return jsonify({ - 'webhook': webhook.to_dict(include_secret=True), - 'message': 'Webhook created successfully' - }), 201 + + return jsonify({"webhook": webhook.to_dict(include_secret=True), "message": "Webhook created successfully"}), 201 -@api_v1_bp.route('/webhooks/', methods=['GET']) -@require_api_token('read:webhooks') +@api_v1_bp.route("/webhooks/", methods=["GET"]) +@require_api_token("read:webhooks") def get_webhook(webhook_id): """Get a specific webhook --- @@ -3901,16 +3995,16 @@ def get_webhook(webhook_id): description: Webhook not found """ webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - - return jsonify({'webhook': webhook.to_dict()}) + return jsonify({"error": "Access denied"}), 403 + return jsonify({"webhook": webhook.to_dict()}) -@api_v1_bp.route('/webhooks/', methods=['PUT', 'PATCH']) -@require_api_token('write:webhooks') + +@api_v1_bp.route("/webhooks/", methods=["PUT", "PATCH"]) +@require_api_token("write:webhooks") def update_webhook(webhook_id): """Update a webhook --- @@ -3930,69 +4024,68 @@ def update_webhook(webhook_id): description: Webhook not found """ webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + data = request.get_json() or {} - + # Update fields - if 'name' in data: - webhook.name = data['name'] - if 'description' in data: - webhook.description = data['description'] - if 'url' in data: + if "name" in data: + webhook.name = data["name"] + if "description" in data: + webhook.description = data["description"] + if "url" in data: # Validate URL try: from urllib.parse import urlparse - parsed = urlparse(data['url']) + + parsed = urlparse(data["url"]) if not parsed.scheme or not parsed.netloc: - return jsonify({'error': 'Invalid URL format'}), 400 - if parsed.scheme not in ['http', 'https']: - return jsonify({'error': 'URL must use http or https'}), 400 + return jsonify({"error": "Invalid URL format"}), 400 + if parsed.scheme not in ["http", "https"]: + return jsonify({"error": "URL must use http or https"}), 400 except Exception: - return jsonify({'error': 'Invalid URL format'}), 400 - webhook.url = data['url'] - if 'events' in data: - if not isinstance(data['events'], list): - return jsonify({'error': 'events must be a list'}), 400 + return jsonify({"error": "Invalid URL format"}), 400 + webhook.url = data["url"] + if "events" in data: + if not isinstance(data["events"], list): + return jsonify({"error": "events must be a list"}), 400 # Validate events from app.utils.webhook_service import WebhookService + available_events = WebhookService.get_available_events() - for event in data['events']: - if event != '*' and event not in available_events: - return jsonify({'error': f'Invalid event type: {event}'}), 400 - webhook.events = data['events'] - if 'http_method' in data: - if data['http_method'] not in ['POST', 'PUT', 'PATCH']: - return jsonify({'error': 'http_method must be POST, PUT, or PATCH'}), 400 - webhook.http_method = data['http_method'] - if 'content_type' in data: - webhook.content_type = data['content_type'] - if 'headers' in data: - webhook.headers = data['headers'] - if 'is_active' in data: - webhook.is_active = bool(data['is_active']) - if 'max_retries' in data: - webhook.max_retries = int(data['max_retries']) - if 'retry_delay_seconds' in data: - webhook.retry_delay_seconds = int(data['retry_delay_seconds']) - if 'timeout_seconds' in data: - webhook.timeout_seconds = int(data['timeout_seconds']) - if 'generate_secret' in data and data['generate_secret']: + for event in data["events"]: + if event != "*" and event not in available_events: + return jsonify({"error": f"Invalid event type: {event}"}), 400 + webhook.events = data["events"] + if "http_method" in data: + if data["http_method"] not in ["POST", "PUT", "PATCH"]: + return jsonify({"error": "http_method must be POST, PUT, or PATCH"}), 400 + webhook.http_method = data["http_method"] + if "content_type" in data: + webhook.content_type = data["content_type"] + if "headers" in data: + webhook.headers = data["headers"] + if "is_active" in data: + webhook.is_active = bool(data["is_active"]) + if "max_retries" in data: + webhook.max_retries = int(data["max_retries"]) + if "retry_delay_seconds" in data: + webhook.retry_delay_seconds = int(data["retry_delay_seconds"]) + if "timeout_seconds" in data: + webhook.timeout_seconds = int(data["timeout_seconds"]) + if "generate_secret" in data and data["generate_secret"]: webhook.set_secret() - + db.session.commit() - - return jsonify({ - 'webhook': webhook.to_dict(), - 'message': 'Webhook updated successfully' - }) + return jsonify({"webhook": webhook.to_dict(), "message": "Webhook updated successfully"}) -@api_v1_bp.route('/webhooks/', methods=['DELETE']) -@require_api_token('write:webhooks') + +@api_v1_bp.route("/webhooks/", methods=["DELETE"]) +@require_api_token("write:webhooks") def delete_webhook(webhook_id): """Delete a webhook --- @@ -4012,19 +4105,19 @@ def delete_webhook(webhook_id): description: Webhook not found """ webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + db.session.delete(webhook) db.session.commit() - - return jsonify({'message': 'Webhook deleted successfully'}) + return jsonify({"message": "Webhook deleted successfully"}) -@api_v1_bp.route('/webhooks//deliveries', methods=['GET']) -@require_api_token('read:webhooks') + +@api_v1_bp.route("/webhooks//deliveries", methods=["GET"]) +@require_api_token("read:webhooks") def list_webhook_deliveries(webhook_id): """List deliveries for a webhook --- @@ -4052,31 +4145,28 @@ def list_webhook_deliveries(webhook_id): description: List of deliveries """ webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not g.api_user.is_admin and webhook.user_id != g.api_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + query = WebhookDelivery.query.filter_by(webhook_id=webhook_id) - + # Filter by status - status = request.args.get('status') + status = request.args.get("status") if status: query = query.filter_by(status=status) - + query = query.order_by(WebhookDelivery.started_at.desc()) - + # Paginate result = paginate_query(query) - - return jsonify({ - 'deliveries': [d.to_dict() for d in result['items']], - 'pagination': result['pagination'] - }) + + return jsonify({"deliveries": [d.to_dict() for d in result["items"]], "pagination": result["pagination"]}) -@api_v1_bp.route('/webhooks/events', methods=['GET']) -@require_api_token('read:webhooks') +@api_v1_bp.route("/webhooks/events", methods=["GET"]) +@require_api_token("read:webhooks") def list_webhook_events(): """Get list of available webhook event types --- @@ -4089,158 +4179,154 @@ def list_webhook_events(): description: List of available event types """ from app.utils.webhook_service import WebhookService + events = WebhookService.get_available_events() - - return jsonify({'events': events}) + + return jsonify({"events": events}) # ==================== Inventory ==================== -@api_v1_bp.route('/inventory/items', methods=['GET']) -@require_api_token('read:projects') # Use existing scope for now + +@api_v1_bp.route("/inventory/items", methods=["GET"]) +@require_api_token("read:projects") # Use existing scope for now def list_stock_items_api(): """List stock items""" - search = request.args.get('search', '').strip() - category = request.args.get('category', '') - active_only = request.args.get('active_only', 'true').lower() == 'true' - + search = request.args.get("search", "").strip() + category = request.args.get("category", "") + active_only = request.args.get("active_only", "true").lower() == "true" + query = StockItem.query - + if active_only: query = query.filter_by(is_active=True) - + if search: like = f"%{search}%" - query = query.filter( - or_( - StockItem.sku.ilike(like), - StockItem.name.ilike(like), - StockItem.barcode.ilike(like) - ) - ) - + query = query.filter(or_(StockItem.sku.ilike(like), StockItem.name.ilike(like), StockItem.barcode.ilike(like))) + if category: query = query.filter_by(category=category) - + result = paginate_query(query.order_by(StockItem.name)) - result['items'] = [item.to_dict() for item in result['items']] - + result["items"] = [item.to_dict() for item in result["items"]] + return jsonify(result) -@api_v1_bp.route('/inventory/items/', methods=['GET']) -@require_api_token('read:projects') +@api_v1_bp.route("/inventory/items/", methods=["GET"]) +@require_api_token("read:projects") def get_stock_item_api(item_id): """Get stock item details""" item = StockItem.query.get_or_404(item_id) - return jsonify({'item': item.to_dict()}) + return jsonify({"item": item.to_dict()}) -@api_v1_bp.route('/inventory/items//availability', methods=['GET']) -@require_api_token('read:projects') +@api_v1_bp.route("/inventory/items//availability", methods=["GET"]) +@require_api_token("read:projects") def get_stock_availability_api(item_id): """Get stock availability for an item across warehouses""" item = StockItem.query.get_or_404(item_id) - warehouse_id = request.args.get('warehouse_id', type=int) - + warehouse_id = request.args.get("warehouse_id", type=int) + query = WarehouseStock.query.filter_by(stock_item_id=item_id) if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + stock_levels = query.all() - + availability = [] for stock in stock_levels: - availability.append({ - 'warehouse_id': stock.warehouse_id, - 'warehouse_code': stock.warehouse.code, - 'warehouse_name': stock.warehouse.name, - 'quantity_on_hand': float(stock.quantity_on_hand), - 'quantity_reserved': float(stock.quantity_reserved), - 'quantity_available': float(stock.quantity_available), - 'location': stock.location - }) - - return jsonify({ - 'item_id': item_id, - 'item_sku': item.sku, - 'item_name': item.name, - 'availability': availability - }) - - -@api_v1_bp.route('/inventory/warehouses', methods=['GET']) -@require_api_token('read:projects') + availability.append( + { + "warehouse_id": stock.warehouse_id, + "warehouse_code": stock.warehouse.code, + "warehouse_name": stock.warehouse.name, + "quantity_on_hand": float(stock.quantity_on_hand), + "quantity_reserved": float(stock.quantity_reserved), + "quantity_available": float(stock.quantity_available), + "location": stock.location, + } + ) + + return jsonify({"item_id": item_id, "item_sku": item.sku, "item_name": item.name, "availability": availability}) + + +@api_v1_bp.route("/inventory/warehouses", methods=["GET"]) +@require_api_token("read:projects") def list_warehouses_api(): """List warehouses""" - active_only = request.args.get('active_only', 'true').lower() == 'true' - + active_only = request.args.get("active_only", "true").lower() == "true" + query = Warehouse.query if active_only: query = query.filter_by(is_active=True) - + result = paginate_query(query.order_by(Warehouse.code)) - result['items'] = [wh.to_dict() for wh in result['items']] - + result["items"] = [wh.to_dict() for wh in result["items"]] + return jsonify(result) -@api_v1_bp.route('/inventory/stock-levels', methods=['GET']) -@require_api_token('read:projects') +@api_v1_bp.route("/inventory/stock-levels", methods=["GET"]) +@require_api_token("read:projects") def get_stock_levels_api(): """Get stock levels""" - warehouse_id = request.args.get('warehouse_id', type=int) - stock_item_id = request.args.get('stock_item_id', type=int) - category = request.args.get('category', '') - + warehouse_id = request.args.get("warehouse_id", type=int) + stock_item_id = request.args.get("stock_item_id", type=int) + category = request.args.get("category", "") + query = WarehouseStock.query.join(StockItem).join(Warehouse) - + if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + if stock_item_id: query = query.filter_by(stock_item_id=stock_item_id) - + if category: query = query.filter(StockItem.category == category) - + stock_levels = query.order_by(Warehouse.code, StockItem.name).all() - + levels = [] for stock in stock_levels: - levels.append({ - 'warehouse': stock.warehouse.to_dict(), - 'stock_item': stock.stock_item.to_dict(), - 'quantity_on_hand': float(stock.quantity_on_hand), - 'quantity_reserved': float(stock.quantity_reserved), - 'quantity_available': float(stock.quantity_available), - 'location': stock.location - }) - - return jsonify({'stock_levels': levels}) - - -@api_v1_bp.route('/inventory/movements', methods=['POST']) -@require_api_token('write:projects') + levels.append( + { + "warehouse": stock.warehouse.to_dict(), + "stock_item": stock.stock_item.to_dict(), + "quantity_on_hand": float(stock.quantity_on_hand), + "quantity_reserved": float(stock.quantity_reserved), + "quantity_available": float(stock.quantity_available), + "location": stock.location, + } + ) + + return jsonify({"stock_levels": levels}) + + +@api_v1_bp.route("/inventory/movements", methods=["POST"]) +@require_api_token("write:projects") def create_stock_movement_api(): """Create a stock movement""" data = request.get_json() or {} - - movement_type = data.get('movement_type', 'adjustment') - stock_item_id = data.get('stock_item_id') - warehouse_id = data.get('warehouse_id') - quantity = data.get('quantity') - reason = data.get('reason') - notes = data.get('notes') - reference_type = data.get('reference_type') - reference_id = data.get('reference_id') - unit_cost = data.get('unit_cost') - + + movement_type = data.get("movement_type", "adjustment") + stock_item_id = data.get("stock_item_id") + warehouse_id = data.get("warehouse_id") + quantity = data.get("quantity") + reason = data.get("reason") + notes = data.get("notes") + reference_type = data.get("reference_type") + reference_id = data.get("reference_id") + unit_cost = data.get("unit_cost") + if not stock_item_id or not warehouse_id or quantity is None: - return jsonify({'error': 'stock_item_id, warehouse_id, and quantity are required'}), 400 - + return jsonify({"error": "stock_item_id, warehouse_id, and quantity are required"}), 400 + try: from decimal import Decimal + movement, updated_stock = StockMovement.record_movement( movement_type=movement_type, stock_item_id=stock_item_id, @@ -4252,232 +4338,247 @@ def create_stock_movement_api(): unit_cost=Decimal(str(unit_cost)) if unit_cost else None, reason=reason, notes=notes, - update_stock=True + update_stock=True, ) - + db.session.commit() - - return jsonify({ - 'message': 'Stock movement recorded successfully', - 'movement': movement.to_dict(), - 'updated_stock': updated_stock.to_dict() if updated_stock else None - }), 201 + + return ( + jsonify( + { + "message": "Stock movement recorded successfully", + "movement": movement.to_dict(), + "updated_stock": updated_stock.to_dict() if updated_stock else None, + } + ), + 201, + ) except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 # ==================== Suppliers API ==================== -@api_v1_bp.route('/inventory/suppliers', methods=['GET']) -@require_api_token('read:projects') + +@api_v1_bp.route("/inventory/suppliers", methods=["GET"]) +@require_api_token("read:projects") def list_suppliers_api(): """List suppliers""" from app.models import Supplier from sqlalchemy import or_ - - search = request.args.get('search', '').strip() - active_only = request.args.get('active_only', 'true').lower() == 'true' - + + search = request.args.get("search", "").strip() + active_only = request.args.get("active_only", "true").lower() == "true" + query = Supplier.query - + if active_only: query = query.filter_by(is_active=True) - + if search: like = f"%{search}%" - query = query.filter( - or_( - Supplier.code.ilike(like), - Supplier.name.ilike(like) - ) - ) - + query = query.filter(or_(Supplier.code.ilike(like), Supplier.name.ilike(like))) + result = paginate_query(query.order_by(Supplier.name)) - result['items'] = [supplier.to_dict() for supplier in result['items']] - + result["items"] = [supplier.to_dict() for supplier in result["items"]] + return jsonify(result) -@api_v1_bp.route('/inventory/suppliers/', methods=['GET']) -@require_api_token('read:projects') +@api_v1_bp.route("/inventory/suppliers/", methods=["GET"]) +@require_api_token("read:projects") def get_supplier_api(supplier_id): """Get supplier details""" from app.models import Supplier + supplier = Supplier.query.get_or_404(supplier_id) - return jsonify({'supplier': supplier.to_dict()}) + return jsonify({"supplier": supplier.to_dict()}) -@api_v1_bp.route('/inventory/suppliers//stock-items', methods=['GET']) -@require_api_token('read:projects') +@api_v1_bp.route("/inventory/suppliers//stock-items", methods=["GET"]) +@require_api_token("read:projects") def get_supplier_stock_items_api(supplier_id): """Get stock items from a supplier""" from app.models import Supplier, SupplierStockItem - + supplier = Supplier.query.get_or_404(supplier_id) - supplier_items = SupplierStockItem.query.join(Supplier).filter( - Supplier.id == supplier_id, - SupplierStockItem.is_active == True - ).all() - + supplier_items = ( + SupplierStockItem.query.join(Supplier) + .filter(Supplier.id == supplier_id, SupplierStockItem.is_active == True) + .all() + ) + items = [] for si in supplier_items: item_dict = si.to_dict() - item_dict['stock_item'] = si.stock_item.to_dict() if si.stock_item else None + item_dict["stock_item"] = si.stock_item.to_dict() if si.stock_item else None items.append(item_dict) - - return jsonify({'items': items}) + + return jsonify({"items": items}) # ==================== Purchase Orders API ==================== -@api_v1_bp.route('/inventory/purchase-orders', methods=['GET']) -@require_api_token('read:projects') + +@api_v1_bp.route("/inventory/purchase-orders", methods=["GET"]) +@require_api_token("read:projects") def list_purchase_orders_api(): """List purchase orders""" from app.models import PurchaseOrder from sqlalchemy import or_ - - status = request.args.get('status', '') - supplier_id = request.args.get('supplier_id', type=int) - + + status = request.args.get("status", "") + supplier_id = request.args.get("supplier_id", type=int) + query = PurchaseOrder.query - + if status: query = query.filter_by(status=status) - + if supplier_id: query = query.filter_by(supplier_id=supplier_id) - + result = paginate_query(query.order_by(PurchaseOrder.order_date.desc())) - result['items'] = [po.to_dict() for po in result['items']] - + result["items"] = [po.to_dict() for po in result["items"]] + return jsonify(result) -@api_v1_bp.route('/inventory/purchase-orders/', methods=['GET']) -@require_api_token('read:projects') +@api_v1_bp.route("/inventory/purchase-orders/", methods=["GET"]) +@require_api_token("read:projects") def get_purchase_order_api(po_id): """Get purchase order details""" from app.models import PurchaseOrder + purchase_order = PurchaseOrder.query.get_or_404(po_id) - return jsonify({'purchase_order': purchase_order.to_dict()}) + return jsonify({"purchase_order": purchase_order.to_dict()}) -@api_v1_bp.route('/inventory/purchase-orders', methods=['POST']) -@require_api_token('write:projects') +@api_v1_bp.route("/inventory/purchase-orders", methods=["POST"]) +@require_api_token("write:projects") def create_purchase_order_api(): """Create a purchase order""" from app.models import PurchaseOrder, PurchaseOrderItem, Supplier from datetime import datetime from decimal import Decimal - + data = request.get_json() or {} - - supplier_id = data.get('supplier_id') + + supplier_id = data.get("supplier_id") if not supplier_id: - return jsonify({'error': 'supplier_id is required'}), 400 - + return jsonify({"error": "supplier_id is required"}), 400 + try: # Generate PO number last_po = PurchaseOrder.query.order_by(PurchaseOrder.id.desc()).first() next_id = (last_po.id + 1) if last_po else 1 po_number = f"PO-{datetime.now().strftime('%Y%m%d')}-{next_id:04d}" - - order_date = datetime.strptime(data.get('order_date'), '%Y-%m-%d').date() if data.get('order_date') else datetime.now().date() - expected_delivery_date = datetime.strptime(data.get('expected_delivery_date'), '%Y-%m-%d').date() if data.get('expected_delivery_date') else None - + + order_date = ( + datetime.strptime(data.get("order_date"), "%Y-%m-%d").date() + if data.get("order_date") + else datetime.now().date() + ) + expected_delivery_date = ( + datetime.strptime(data.get("expected_delivery_date"), "%Y-%m-%d").date() + if data.get("expected_delivery_date") + else None + ) + purchase_order = PurchaseOrder( po_number=po_number, supplier_id=supplier_id, order_date=order_date, created_by=g.api_user.id, expected_delivery_date=expected_delivery_date, - notes=data.get('notes'), - internal_notes=data.get('internal_notes'), - currency_code=data.get('currency_code', 'EUR') + notes=data.get("notes"), + internal_notes=data.get("internal_notes"), + currency_code=data.get("currency_code", "EUR"), ) db.session.add(purchase_order) db.session.flush() - + # Handle items - items = data.get('items', []) + items = data.get("items", []) for item_data in items: item = PurchaseOrderItem( purchase_order_id=purchase_order.id, - description=item_data.get('description', ''), - quantity_ordered=Decimal(str(item_data.get('quantity_ordered', 1))), - unit_cost=Decimal(str(item_data.get('unit_cost', 0))), - stock_item_id=item_data.get('stock_item_id'), - supplier_stock_item_id=item_data.get('supplier_stock_item_id'), - supplier_sku=item_data.get('supplier_sku'), - warehouse_id=item_data.get('warehouse_id'), - currency_code=purchase_order.currency_code + description=item_data.get("description", ""), + quantity_ordered=Decimal(str(item_data.get("quantity_ordered", 1))), + unit_cost=Decimal(str(item_data.get("unit_cost", 0))), + stock_item_id=item_data.get("stock_item_id"), + supplier_stock_item_id=item_data.get("supplier_stock_item_id"), + supplier_sku=item_data.get("supplier_sku"), + warehouse_id=item_data.get("warehouse_id"), + currency_code=purchase_order.currency_code, ) db.session.add(item) - + purchase_order.calculate_totals() db.session.commit() - - return jsonify({ - 'message': 'Purchase order created successfully', - 'purchase_order': purchase_order.to_dict() - }), 201 + + return ( + jsonify({"message": "Purchase order created successfully", "purchase_order": purchase_order.to_dict()}), + 201, + ) except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 -@api_v1_bp.route('/inventory/purchase-orders//receive', methods=['POST']) -@require_api_token('write:projects') +@api_v1_bp.route("/inventory/purchase-orders//receive", methods=["POST"]) +@require_api_token("write:projects") def receive_purchase_order_api(po_id): """Receive a purchase order""" from app.models import PurchaseOrder from datetime import datetime - + purchase_order = PurchaseOrder.query.get_or_404(po_id) data = request.get_json() or {} - + try: from decimal import Decimal - + # Update received quantities if provided - items_data = data.get('items', []) + items_data = data.get("items", []) if items_data: for item_data in items_data: - item_id = item_data.get('item_id') - quantity_received = item_data.get('quantity_received') + item_id = item_data.get("item_id") + quantity_received = item_data.get("quantity_received") if item_id and quantity_received is not None: item = purchase_order.items.filter_by(id=item_id).first() if item: item.quantity_received = Decimal(str(quantity_received)) - - received_date_str = data.get('received_date') - received_date = datetime.strptime(received_date_str, '%Y-%m-%d').date() if received_date_str else datetime.now().date() + + received_date_str = data.get("received_date") + received_date = ( + datetime.strptime(received_date_str, "%Y-%m-%d").date() if received_date_str else datetime.now().date() + ) purchase_order.mark_as_received(received_date) - + db.session.commit() - - return jsonify({ - 'message': 'Purchase order received successfully', - 'purchase_order': purchase_order.to_dict() - }), 200 + + return ( + jsonify({"message": "Purchase order received successfully", "purchase_order": purchase_order.to_dict()}), + 200, + ) except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 # ==================== Error Handlers ==================== + @api_v1_bp.errorhandler(404) def not_found(error): """Handle 404 errors""" - return jsonify({'error': 'Resource not found'}), 404 + return jsonify({"error": "Resource not found"}), 404 @api_v1_bp.errorhandler(500) def internal_error(error): """Handle 500 errors""" db.session.rollback() - return jsonify({'error': 'Internal server error'}), 500 - + return jsonify({"error": "Internal server error"}), 500 diff --git a/app/routes/audit_logs.py b/app/routes/audit_logs.py index 402f1d65..b7e4d008 100644 --- a/app/routes/audit_logs.py +++ b/app/routes/audit_logs.py @@ -9,72 +9,70 @@ from sqlalchemy import inspect as sqlalchemy_inspect from datetime import datetime, timedelta -audit_logs_bp = Blueprint('audit_logs', __name__) +audit_logs_bp = Blueprint("audit_logs", __name__) -@audit_logs_bp.route('/audit-logs') +@audit_logs_bp.route("/audit-logs") @login_required -@admin_or_permission_required('view_audit_logs') +@admin_or_permission_required("view_audit_logs") def list_audit_logs(): """List audit logs with filtering options""" # Check if table exists first reset_audit_table_cache() if not check_audit_table_exists(force_check=True): from flask import flash - flash(_('Audit logs table does not exist. Please run: flask db upgrade'), 'warning') - return render_template('audit_logs/list.html', + + flash(_("Audit logs table does not exist. Please run: flask db upgrade"), "warning") + return render_template( + "audit_logs/list.html", audit_logs=[], pagination=None, - entity_type='', + entity_type="", entity_id=None, user_id=None, - action='', + action="", days=30, entity_types=[], users=[], ) - - page = request.args.get('page', 1, type=int) - entity_type = request.args.get('entity_type', '').strip() - entity_id = request.args.get('entity_id', type=int) - user_id = request.args.get('user_id', type=int) - action = request.args.get('action', '').strip() - days = request.args.get('days', 30, type=int) - + + page = request.args.get("page", 1, type=int) + entity_type = request.args.get("entity_type", "").strip() + entity_id = request.args.get("entity_id", type=int) + user_id = request.args.get("user_id", type=int) + action = request.args.get("action", "").strip() + days = request.args.get("days", 30, type=int) + # Build query query = AuditLog.query - + # Filter by entity type if entity_type: query = query.filter_by(entity_type=entity_type) - + # Filter by entity ID if entity_id: query = query.filter_by(entity_id=entity_id) - + # Filter by user if user_id: query = query.filter_by(user_id=user_id) - + # Filter by action if action: query = query.filter_by(action=action) - + # Filter by date range if days: cutoff_date = datetime.utcnow() - timedelta(days=days) query = query.filter(AuditLog.created_at >= cutoff_date) - + # Order by most recent first query = query.order_by(AuditLog.created_at.desc()) - + # Paginate - pagination = query.paginate( - page=page, - per_page=50, - error_out=False - ) - + pagination = query.paginate(page=page, per_page=50, error_out=False) + # Get unique entity types for filter dropdown try: entity_types = db.session.query(AuditLog.entity_type).distinct().all() @@ -83,16 +81,16 @@ def list_audit_logs(): except Exception: # Table might not exist yet entity_types = [] - + # Get users for filter dropdown try: users_with_logs = db.session.query(User).join(AuditLog).distinct().all() except Exception: # Table might not exist yet or no logs yet users_with_logs = [] - + return render_template( - 'audit_logs/list.html', + "audit_logs/list.html", audit_logs=pagination.items, pagination=pagination, entity_type=entity_type, @@ -105,80 +103,88 @@ def list_audit_logs(): ) -@audit_logs_bp.route('/audit-logs/') +@audit_logs_bp.route("/audit-logs/") @login_required -@admin_or_permission_required('view_audit_logs') +@admin_or_permission_required("view_audit_logs") def view_audit_log(log_id): """View details of a specific audit log entry""" audit_log = AuditLog.query.get_or_404(log_id) - + return render_template( - 'audit_logs/view.html', + "audit_logs/view.html", audit_log=audit_log, ) -@audit_logs_bp.route('/audit-logs/entity//') +@audit_logs_bp.route("/audit-logs/entity//") @login_required -@admin_or_permission_required('view_audit_logs') +@admin_or_permission_required("view_audit_logs") def entity_history(entity_type, entity_id): """View audit history for a specific entity""" - page = request.args.get('page', 1, type=int) - + page = request.args.get("page", 1, type=int) + # Get audit logs for this entity - query = AuditLog.query.filter_by( - entity_type=entity_type, - entity_id=entity_id - ).order_by(AuditLog.created_at.desc()) - - pagination = query.paginate( - page=page, - per_page=50, - error_out=False - ) - + query = AuditLog.query.filter_by(entity_type=entity_type, entity_id=entity_id).order_by(AuditLog.created_at.desc()) + + pagination = query.paginate(page=page, per_page=50, error_out=False) + # Try to get the entity name entity_name = None try: # Import models dynamically from app.models import ( - Project, Task, TimeEntry, Invoice, Client, User, Expense, - Payment, Comment, ProjectCost, KanbanColumn, TimeEntryTemplate, - ClientNote, WeeklyTimeGoal, CalendarEvent, BudgetAlert + Project, + Task, + TimeEntry, + Invoice, + Client, + User, + Expense, + Payment, + Comment, + ProjectCost, + KanbanColumn, + TimeEntryTemplate, + ClientNote, + WeeklyTimeGoal, + CalendarEvent, + BudgetAlert, ) - + model_map = { - 'Project': Project, - 'Task': Task, - 'TimeEntry': TimeEntry, - 'Invoice': Invoice, - 'Client': Client, - 'User': User, - 'Expense': Expense, - 'Payment': Payment, - 'Comment': Comment, - 'ProjectCost': ProjectCost, - 'KanbanColumn': KanbanColumn, - 'TimeEntryTemplate': TimeEntryTemplate, - 'ClientNote': ClientNote, - 'WeeklyTimeGoal': WeeklyTimeGoal, - 'CalendarEvent': CalendarEvent, - 'BudgetAlert': BudgetAlert, + "Project": Project, + "Task": Task, + "TimeEntry": TimeEntry, + "Invoice": Invoice, + "Client": Client, + "User": User, + "Expense": Expense, + "Payment": Payment, + "Comment": Comment, + "ProjectCost": ProjectCost, + "KanbanColumn": KanbanColumn, + "TimeEntryTemplate": TimeEntryTemplate, + "ClientNote": ClientNote, + "WeeklyTimeGoal": WeeklyTimeGoal, + "CalendarEvent": CalendarEvent, + "BudgetAlert": BudgetAlert, } - + model_class = model_map.get(entity_type) if model_class: entity = model_class.query.get(entity_id) if entity: - entity_name = getattr(entity, 'name', None) or \ - getattr(entity, 'title', None) or \ - getattr(entity, 'username', None) or \ - str(entity) + entity_name = ( + getattr(entity, "name", None) + or getattr(entity, "title", None) + or getattr(entity, "username", None) + or str(entity) + ) except Exception: pass - + return render_template( - 'audit_logs/entity_history.html', + "audit_logs/entity_history.html", audit_logs=pagination.items, pagination=pagination, entity_type=entity_type, @@ -187,20 +193,20 @@ def entity_history(entity_type, entity_id): ) -@audit_logs_bp.route('/api/audit-logs') +@audit_logs_bp.route("/api/audit-logs") @login_required -@admin_or_permission_required('view_audit_logs') +@admin_or_permission_required("view_audit_logs") def api_audit_logs(): """API endpoint for audit logs (JSON)""" - page = request.args.get('page', 1, type=int) - entity_type = request.args.get('entity_type', '').strip() - entity_id = request.args.get('entity_id', type=int) - user_id = request.args.get('user_id', type=int) - action = request.args.get('action', '').strip() - limit = request.args.get('limit', 100, type=int) - + page = request.args.get("page", 1, type=int) + entity_type = request.args.get("entity_type", "").strip() + entity_id = request.args.get("entity_id", type=int) + user_id = request.args.get("user_id", type=int) + action = request.args.get("action", "").strip() + limit = request.args.get("limit", 100, type=int) + query = AuditLog.query - + if entity_type: query = query.filter_by(entity_type=entity_type) if entity_id: @@ -209,53 +215,46 @@ def api_audit_logs(): query = query.filter_by(user_id=user_id) if action: query = query.filter_by(action=action) - + query = query.order_by(AuditLog.created_at.desc()).limit(limit) - + audit_logs = query.all() - - return jsonify({ - 'audit_logs': [log.to_dict() for log in audit_logs], - 'count': len(audit_logs) - }) + + return jsonify({"audit_logs": [log.to_dict() for log in audit_logs], "count": len(audit_logs)}) -@audit_logs_bp.route('/api/audit-logs/status') +@audit_logs_bp.route("/api/audit-logs/status") @login_required -@admin_or_permission_required('view_audit_logs') +@admin_or_permission_required("view_audit_logs") def audit_logs_status(): """Check audit logs table status and reset cache if needed""" try: # Force check table existence reset_audit_table_cache() table_exists = check_audit_table_exists(force_check=True) - - status = { - 'table_exists': table_exists, - 'enabled': table_exists - } - + + status = {"table_exists": table_exists, "enabled": table_exists} + if table_exists: try: count = AuditLog.query.count() - status['total_logs'] = count - + status["total_logs"] = count + # Check recent activity recent = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(5).all() - status['recent_logs'] = [log.to_dict() for log in recent] + status["recent_logs"] = [log.to_dict() for log in recent] except Exception as e: - status['error'] = str(e) + status["error"] = str(e) else: # Check what tables do exist try: inspector = sqlalchemy_inspect(db.engine) tables = inspector.get_table_names() - status['available_tables'] = sorted(tables) - status['message'] = 'audit_logs table does not exist. Run: flask db upgrade' + status["available_tables"] = sorted(tables) + status["message"] = "audit_logs table does not exist. Run: flask db upgrade" except Exception as e: - status['error'] = f"Could not check tables: {e}" - + status["error"] = f"Could not check tables: {e}" + return jsonify(status) except Exception as e: - return jsonify({'error': str(e)}), 500 - + return jsonify({"error": str(e)}), 500 diff --git a/app/routes/auth.py b/app/routes/auth.py index 9b65d30f..ad0be452 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -1,4 +1,14 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, session, current_app, send_from_directory +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + flash, + session, + current_app, + send_from_directory, +) from flask_login import login_user, logout_user, login_required, current_user from app import db, log_event, track_event from app.models import User @@ -10,312 +20,387 @@ from app.utils.posthog_funnels import track_onboarding_started -auth_bp = Blueprint('auth', __name__) +auth_bp = Blueprint("auth", __name__) # Allowed file extensions for user avatars (avoid SVG due to XSS risk) -ALLOWED_AVATAR_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'} +ALLOWED_AVATAR_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"} def allowed_avatar_file(filename: str) -> bool: - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_AVATAR_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_AVATAR_EXTENSIONS def get_avatar_upload_folder() -> str: """Get the upload folder path for user avatars and ensure it exists.""" import os + # Store avatars in /data volume to persist between container updates - upload_folder = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'avatars') + upload_folder = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "avatars") os.makedirs(upload_folder, exist_ok=True) return upload_folder -@auth_bp.route('/login', methods=['GET', 'POST']) + +@auth_bp.route("/login", methods=["GET", "POST"]) @limiter.limit("5 per minute", methods=["POST"]) # rate limit login attempts def login(): """Login page. Local username login is allowed only if AUTH_METHOD != 'oidc'.""" - if request.method == 'GET': + if request.method == "GET": try: - current_app.logger.info("GET /login from %s", request.headers.get('X-Forwarded-For') or request.remote_addr) + current_app.logger.info("GET /login from %s", request.headers.get("X-Forwarded-For") or request.remote_addr) except Exception: pass - + if current_user.is_authenticated: - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Get authentication method try: - auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower() + auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() except Exception: - auth_method = 'local' + auth_method = "local" # Determine if password authentication is required - requires_password = auth_method in ('local', 'both') - + requires_password = auth_method in ("local", "both") + # If OIDC-only mode, redirect to OIDC login start - if auth_method == 'oidc': - return redirect(url_for('auth.login_oidc', next=request.args.get('next'))) + if auth_method == "oidc": + return redirect(url_for("auth.login_oidc", next=request.args.get("next"))) - if request.method == 'POST': + if request.method == "POST": try: - username = request.form.get('username', '').strip().lower() - password = request.form.get('password', '') - current_app.logger.info("POST /login (username=%s, auth_method=%s) from %s", username or '', auth_method, request.headers.get('X-Forwarded-For') or request.remote_addr) - + username = request.form.get("username", "").strip().lower() + password = request.form.get("password", "") + current_app.logger.info( + "POST /login (username=%s, auth_method=%s) from %s", + username or "", + auth_method, + request.headers.get("X-Forwarded-For") or request.remote_addr, + ) + if not username: log_event("auth.login_failed", reason="empty_username", auth_method=auth_method) - flash(_('Username is required'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) - + flash(_("Username is required"), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) + # Normalize admin usernames from config try: admin_usernames = [u.strip().lower() for u in (Config.ADMIN_USERNAMES or [])] except Exception: - admin_usernames = ['admin'] + admin_usernames = ["admin"] # Check if user exists user = User.query.filter_by(username=username).first() - current_app.logger.info("User lookup for '%s': %s", username, 'found' if user else 'not found') - + current_app.logger.info("User lookup for '%s': %s", username, "found" if user else "not found") + if not user: # Check if self-registration is allowed if Config.ALLOW_SELF_REGISTER: # If password auth is required, validate password during self-registration if requires_password: if not password: - flash(_('Password is required to create an account.'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) + flash(_("Password is required to create an account."), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) if len(password) < 8: - flash(_('Password must be at least 8 characters long.'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) - + flash(_("Password must be at least 8 characters long."), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) + # Create new user, promote to admin if username is configured as admin - role = 'admin' if username in admin_usernames else 'user' + role = "admin" if username in admin_usernames else "user" user = User(username=username, role=role) # Set password if password auth is required if requires_password and password: user.set_password(password) db.session.add(user) - if not safe_commit('self_register_user', {'username': username}): + if not safe_commit("self_register_user", {"username": username}): current_app.logger.error("Self-registration failed for '%s' due to DB error", username) - flash(_('Could not create your account due to a database error. Please try again later.'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) + flash( + _("Could not create your account due to a database error. Please try again later."), "error" + ) + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) current_app.logger.info("Created new user '%s'", username) - + # Track onboarding started for new user - track_onboarding_started(user.id, { - "auth_method": auth_method, - "self_registered": True, - "is_admin": role == 'admin' - }) - - flash(_('Welcome! Your account has been created.'), 'success') + track_onboarding_started( + user.id, {"auth_method": auth_method, "self_registered": True, "is_admin": role == "admin"} + ) + + flash(_("Welcome! Your account has been created."), "success") else: log_event("auth.login_failed", username=username, reason="user_not_found", auth_method=auth_method) - flash(_('User not found. Please contact an administrator.'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) + flash(_("User not found. Please contact an administrator."), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) else: # If existing user matches admin usernames, ensure admin role - if username in admin_usernames and user.role != 'admin': - user.role = 'admin' - if not safe_commit('promote_admin_user', {'username': username}): + if username in admin_usernames and user.role != "admin": + user.role = "admin" + if not safe_commit("promote_admin_user", {"username": username}): current_app.logger.error("Failed to promote '%s' to admin due to DB error", username) - flash(_('Could not update your account role due to a database error.'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) - + flash(_("Could not update your account role due to a database error."), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) + # Check if user is active if not user.is_active: log_event("auth.login_failed", user_id=user.id, reason="account_disabled", auth_method=auth_method) - flash(_('Account is disabled. Please contact an administrator.'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) - + flash(_("Account is disabled. Please contact an administrator."), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) + # Handle password authentication based on mode if requires_password: # Password authentication is required if user.has_password: # User has password set - verify it if not password: - log_event("auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method) - flash(_('Password is required'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) - + log_event( + "auth.login_failed", user_id=user.id, reason="password_required", auth_method=auth_method + ) + flash(_("Password is required"), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) + if not user.check_password(password): - log_event("auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method) - flash(_('Invalid username or password'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) + log_event( + "auth.login_failed", user_id=user.id, reason="invalid_password", auth_method=auth_method + ) + flash(_("Invalid username or password"), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) else: # User doesn't have password set - prompt to set one log_event("auth.login_failed", user_id=user.id, reason="no_password_set", auth_method=auth_method) - flash(_('No password is set for your account. Please set a password in your profile to continue.'), 'error') + flash( + _("No password is set for your account. Please set a password in your profile to continue."), + "error", + ) # Still log them in so they can set password in profile login_user(user, remember=True) - return redirect(url_for('auth.edit_profile')) - + return redirect(url_for("auth.edit_profile")) + # For 'none' mode, no password check needed - just log in # Log in the user login_user(user, remember=True) user.update_last_login() current_app.logger.info("User '%s' logged in successfully", user.username) - + # Track successful login log_event("auth.login", user_id=user.id, auth_method=auth_method) track_event(user.id, "auth.login", {"auth_method": auth_method}) - + # Identify user with comprehensive segmentation properties identify_user_with_segments(user.id, user) - + # Set super properties (included in all events) set_super_properties(user.id, user) - + # Redirect to intended page or dashboard - next_page = request.args.get('next') - if not next_page or not next_page.startswith('/'): - next_page = url_for('main.dashboard') + next_page = request.args.get("next") + if not next_page or not next_page.startswith("/"): + next_page = url_for("main.dashboard") current_app.logger.info("Redirecting '%s' to %s", user.username, next_page) - - flash(_('Welcome back, %(username)s!', username=user.username), 'success') + + flash(_("Welcome back, %(username)s!", username=user.username), "success") return redirect(next_page) except Exception as e: current_app.logger.exception("Login error: %s", e) - flash(_('Unexpected error during login. Please try again or check server logs.'), 'error') - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) - - return render_template('auth/login.html', allow_self_register=Config.ALLOW_SELF_REGISTER, auth_method=auth_method, requires_password=requires_password) - -@auth_bp.route('/logout') + flash(_("Unexpected error during login. Please try again or check server logs."), "error") + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) + + return render_template( + "auth/login.html", + allow_self_register=Config.ALLOW_SELF_REGISTER, + auth_method=auth_method, + requires_password=requires_password, + ) + + +@auth_bp.route("/logout") @login_required def logout(): """Logout the current user""" username = current_user.username user_id = current_user.id - + # Track logout event before logging out log_event("auth.logout", user_id=user_id) track_event(user_id, "auth.logout", {}) - + # Try OIDC end-session if enabled and configured try: - auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower() + auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() except Exception: - auth_method = 'local' + auth_method = "local" - id_token = session.pop('oidc_id_token', None) + id_token = session.pop("oidc_id_token", None) logout_user() # Ensure both possible session keys are cleared for compatibility try: - session.pop('_user_id', None) - session.pop('user_id', None) + session.pop("_user_id", None) + session.pop("user_id", None) except Exception: pass - flash(_('Goodbye, %(username)s!', username=username), 'info') + flash(_("Goodbye, %(username)s!", username=username), "info") - if auth_method in ('oidc', 'both'): + if auth_method in ("oidc", "both"): # Only perform RP-Initiated Logout if OIDC_POST_LOGOUT_REDIRECT_URI is explicitly configured - post_logout = getattr(Config, 'OIDC_POST_LOGOUT_REDIRECT_URI', None) + post_logout = getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", None) if post_logout: - client = oauth.create_client('oidc') + client = oauth.create_client("oidc") if client: try: # Build end-session URL if provider supports it metadata = client.load_server_metadata() - end_session_endpoint = metadata.get('end_session_endpoint') or metadata.get('revocation_endpoint') + end_session_endpoint = metadata.get("end_session_endpoint") or metadata.get("revocation_endpoint") if end_session_endpoint: params = {} if id_token: - params['id_token_hint'] = id_token - params['post_logout_redirect_uri'] = post_logout + params["id_token_hint"] = id_token + params["post_logout_redirect_uri"] = post_logout from urllib.parse import urlencode + return redirect(f"{end_session_endpoint}?{urlencode(params)}") except Exception: pass - return redirect(url_for('auth.login')) + return redirect(url_for("auth.login")) -@auth_bp.route('/profile') + +@auth_bp.route("/profile") @login_required def profile(): """User profile page""" - return render_template('auth/profile.html') + return render_template("auth/profile.html") + -@auth_bp.route('/profile/edit', methods=['GET', 'POST']) +@auth_bp.route("/profile/edit", methods=["GET", "POST"]) @login_required def edit_profile(): """Edit user profile""" # Get authentication method to determine if password fields should be shown try: - auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower() + auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() except Exception: - auth_method = 'local' - - requires_password = auth_method in ('local', 'both') - - if request.method == 'POST': + auth_method = "local" + + requires_password = auth_method in ("local", "both") + + if request.method == "POST": # Update real name if provided - full_name = request.form.get('full_name', '').strip() + full_name = request.form.get("full_name", "").strip() current_user.full_name = full_name or None # Update preferred language - preferred_language = (request.form.get('preferred_language') or '').strip().lower() - available = (current_app.config.get('LANGUAGES') or {}).keys() + preferred_language = (request.form.get("preferred_language") or "").strip().lower() + available = (current_app.config.get("LANGUAGES") or {}).keys() if preferred_language in available: current_user.preferred_language = preferred_language # Also set session so it applies immediately - session['preferred_language'] = preferred_language - + session["preferred_language"] = preferred_language + # Handle password update if password auth is required if requires_password: - password = request.form.get('password', '').strip() - password_confirm = request.form.get('password_confirm', '').strip() - + password = request.form.get("password", "").strip() + password_confirm = request.form.get("password_confirm", "").strip() + if password: # Validate password if len(password) < 8: - flash(_('Password must be at least 8 characters long.'), 'error') - return redirect(url_for('auth.edit_profile')) - + flash(_("Password must be at least 8 characters long."), "error") + return redirect(url_for("auth.edit_profile")) + if password != password_confirm: - flash(_('Passwords do not match.'), 'error') - return redirect(url_for('auth.edit_profile')) - + flash(_("Passwords do not match."), "error") + return redirect(url_for("auth.edit_profile")) + # Set the new password current_user.set_password(password) current_app.logger.info("User '%s' updated password", current_user.username) # Handle avatar upload if provided try: - file = request.files.get('avatar') + file = request.files.get("avatar") except Exception: file = None - if file and getattr(file, 'filename', ''): + if file and getattr(file, "filename", ""): filename = file.filename if not allowed_avatar_file(filename): - flash(_('Invalid avatar file type. Allowed: PNG, JPG, JPEG, GIF, WEBP'), 'error') - return redirect(url_for('auth.edit_profile')) + flash(_("Invalid avatar file type. Allowed: PNG, JPG, JPEG, GIF, WEBP"), "error") + return redirect(url_for("auth.edit_profile")) # Validate image content with Pillow try: from PIL import Image + file.stream.seek(0) img = Image.open(file.stream) img.verify() file.stream.seek(0) except Exception: - flash(_('Invalid image file.'), 'error') - return redirect(url_for('auth.edit_profile')) + flash(_("Invalid image file."), "error") + return redirect(url_for("auth.edit_profile")) # Generate unique filename and save import uuid import os - ext = filename.rsplit('.', 1)[1].lower() + + ext = filename.rsplit(".", 1)[1].lower() unique_name = f"avatar_{current_user.id}_{uuid.uuid4().hex[:8]}.{ext}" folder = get_avatar_upload_folder() file_path = os.path.join(folder, unique_name) try: file.save(file_path) except Exception: - flash(_('Failed to save avatar on server.'), 'error') - return redirect(url_for('auth.edit_profile')) + flash(_("Failed to save avatar on server."), "error") + return redirect(url_for("auth.edit_profile")) # Remove old avatar if exists try: - old_filename = getattr(current_user, 'avatar_filename', None) + old_filename = getattr(current_user, "avatar_filename", None) if old_filename: old_path = os.path.join(folder, old_filename) if os.path.exists(old_path): @@ -329,21 +414,22 @@ def edit_profile(): current_user.avatar_filename = unique_name try: db.session.commit() - flash(_('Profile updated successfully'), 'success') + flash(_("Profile updated successfully"), "success") except Exception: db.session.rollback() - flash(_('Could not update your profile due to a database error.'), 'error') - return redirect(url_for('auth.profile')) - - return render_template('auth/edit_profile.html', requires_password=requires_password) + flash(_("Could not update your profile due to a database error."), "error") + return redirect(url_for("auth.profile")) + + return render_template("auth/edit_profile.html", requires_password=requires_password) -@auth_bp.route('/profile/avatar/remove', methods=['POST']) +@auth_bp.route("/profile/avatar/remove", methods=["POST"]) @login_required def remove_avatar(): """Remove the current user's avatar file and clear the field.""" try: import os + folder = get_avatar_upload_folder() if current_user.avatar_filename: path = os.path.join(folder, current_user.avatar_filename) @@ -354,90 +440,97 @@ def remove_avatar(): pass current_user.avatar_filename = None db.session.commit() - flash(_('Avatar removed'), 'success') + flash(_("Avatar removed"), "success") except Exception: db.session.rollback() - flash(_('Failed to remove avatar.'), 'error') - return redirect(url_for('auth.edit_profile')) + flash(_("Failed to remove avatar."), "error") + return redirect(url_for("auth.edit_profile")) # Public route to serve uploaded avatars from the static uploads directory -@auth_bp.route('/uploads/avatars/') +@auth_bp.route("/uploads/avatars/") def serve_uploaded_avatar(filename): folder = get_avatar_upload_folder() return send_from_directory(folder, filename) -@auth_bp.route('/profile/theme', methods=['POST']) +@auth_bp.route("/profile/theme", methods=["POST"]) @login_required def update_theme_preference(): """Persist user theme preference (light|dark|system).""" try: - value = (request.json.get('theme') if request.is_json else request.form.get('theme') or '').strip().lower() + value = (request.json.get("theme") if request.is_json else request.form.get("theme") or "").strip().lower() except Exception: - value = (request.form.get('theme') or '').strip().lower() + value = (request.form.get("theme") or "").strip().lower() - if value not in ('light', 'dark', 'system'): - return ({'error': 'invalid theme value'}, 400) + if value not in ("light", "dark", "system"): + return ({"error": "invalid theme value"}, 400) # Store None for system to allow fallback to system preference - current_user.theme_preference = None if value == 'system' else value + current_user.theme_preference = None if value == "system" else value try: db.session.commit() except Exception: db.session.rollback() - return ({'error': 'failed to save preference'}, 500) + return ({"error": "failed to save preference"}, 500) - return ({'ok': True, 'theme': value}, 200) + return ({"ok": True, "theme": value}, 200) # --- OIDC placeholders (optional integration) --- -@auth_bp.route('/login/oidc') +@auth_bp.route("/login/oidc") def login_oidc(): """Start OIDC login using Authlib.""" try: - auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower() + auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() except Exception: - auth_method = 'local' + auth_method = "local" - if auth_method not in ('oidc', 'both'): - return redirect(url_for('auth.login')) + if auth_method not in ("oidc", "both"): + return redirect(url_for("auth.login")) - client = oauth.create_client('oidc') + client = oauth.create_client("oidc") if not client: - flash(_('Single Sign-On is not configured yet. Please contact an administrator.'), 'warning') - return redirect(url_for('auth.login')) + flash(_("Single Sign-On is not configured yet. Please contact an administrator."), "warning") + return redirect(url_for("auth.login")) # Preserve next redirect - next_page = request.args.get('next') - if next_page and next_page.startswith('/'): - session['oidc_next'] = next_page + next_page = request.args.get("next") + if next_page and next_page.startswith("/"): + session["oidc_next"] = next_page # Determine redirect URI - redirect_uri = getattr(Config, 'OIDC_REDIRECT_URI', None) or url_for('auth.oidc_callback', _external=True) + redirect_uri = getattr(Config, "OIDC_REDIRECT_URI", None) or url_for("auth.oidc_callback", _external=True) # Trigger authorization code flow (with PKCE via client_kwargs) return client.authorize_redirect(redirect_uri) -@auth_bp.route('/auth/oidc/callback') +@auth_bp.route("/auth/oidc/callback") def oidc_callback(): """Handle OIDC callback: exchange code, map claims, upsert user, log them in.""" - client = oauth.create_client('oidc') + client = oauth.create_client("oidc") if not client: - flash(_('Single Sign-On is not configured.'), 'error') - return redirect(url_for('auth.login')) + flash(_("Single Sign-On is not configured."), "error") + return redirect(url_for("auth.login")) try: # Exchange authorization code for tokens current_app.logger.info("OIDC callback: Starting token exchange") token = client.authorize_access_token() - current_app.logger.info("OIDC callback: Token exchange successful, token keys: %s", list(token.keys()) if isinstance(token, dict) else 'not-a-dict') - + current_app.logger.info( + "OIDC callback: Token exchange successful, token keys: %s", + list(token.keys()) if isinstance(token, dict) else "not-a-dict", + ) + # Log raw token structure (mask sensitive data) if isinstance(token, dict): - token_info = {k: (v[:20] + '...' if isinstance(v, str) and len(v) > 20 else v) for k, v in token.items() if k not in ['access_token', 'id_token', 'refresh_token']} + token_info = { + k: (v[:20] + "..." if isinstance(v, str) and len(v) > 20 else v) + for k, v in token.items() + if k not in ["access_token", "id_token", "refresh_token"] + } current_app.logger.debug("OIDC callback: Token info: %s", token_info) - + # Parse ID token claims claims = {} id_token_parsed = False @@ -445,30 +538,35 @@ def oidc_callback(): current_app.logger.info("OIDC callback: Attempting to parse ID token") # Authlib already validates and parses the ID token during authorize_access_token() # The parsed claims should be available in the token dict under 'userinfo' key - if isinstance(token, dict) and 'userinfo' in token: - claims = token.get('userinfo', {}) + if isinstance(token, dict) and "userinfo" in token: + claims = token.get("userinfo", {}) id_token_parsed = True - current_app.logger.info("OIDC callback: ID token claims available from token, claims keys: %s", list(claims.keys())) + current_app.logger.info( + "OIDC callback: ID token claims available from token, claims keys: %s", list(claims.keys()) + ) else: # If not available, parse it manually with nonce from session # Authlib stores the nonce in session during authorize_redirect() - nonce = session.get('_oidc_authlib_nonce_') - current_app.logger.debug("OIDC callback: Nonce from session: %s", 'present' if nonce else 'missing') + nonce = session.get("_oidc_authlib_nonce_") + current_app.logger.debug("OIDC callback: Nonce from session: %s", "present" if nonce else "missing") parsed = client.parse_id_token(token, nonce=nonce) if parsed: claims = parsed id_token_parsed = True - current_app.logger.info("OIDC callback: ID token parsed successfully, claims keys: %s", list(claims.keys())) + current_app.logger.info( + "OIDC callback: ID token parsed successfully, claims keys: %s", list(claims.keys()) + ) else: current_app.logger.warning("OIDC callback: parse_id_token returned None/empty") except Exception as e: current_app.logger.error("OIDC callback: Failed to parse ID token: %s - %s", type(e).__name__, str(e)) # Try to decode the token manually to debug try: - if isinstance(token, dict) and 'id_token' in token: + if isinstance(token, dict) and "id_token" in token: import jwt + # Decode without verification to inspect claims (for debugging only) - unverified = jwt.decode(token['id_token'], options={"verify_signature": False}) + unverified = jwt.decode(token["id_token"], options={"verify_signature": False}) current_app.logger.info("OIDC callback: Unverified ID token claims: %s", list(unverified.keys())) current_app.logger.debug("OIDC callback: Unverified token content: %s", unverified) except Exception as decode_err: @@ -486,7 +584,9 @@ def oidc_callback(): current_app.logger.info("OIDC callback: Userinfo fetched successfully, keys: %s", list(userinfo.keys())) # If ID token parsing failed but userinfo succeeded, use userinfo for critical fields if not id_token_parsed and userinfo: - current_app.logger.warning("OIDC callback: ID token parsing failed, using userinfo as primary source") + current_app.logger.warning( + "OIDC callback: ID token parsing failed, using userinfo as primary source" + ) claims = userinfo else: current_app.logger.warning("OIDC callback: userinfo endpoint returned None/empty") @@ -494,22 +594,27 @@ def oidc_callback(): current_app.logger.error("OIDC callback: Failed to fetch userinfo: %s - %s", type(e).__name__, str(e)) # Resolve fields from claims/userinfo - issuer = (claims.get('iss') or userinfo.get('iss') or '').strip() - sub = (claims.get('sub') or userinfo.get('sub') or '').strip() - - username_claim = getattr(Config, 'OIDC_USERNAME_CLAIM', 'preferred_username') - full_name_claim = getattr(Config, 'OIDC_FULL_NAME_CLAIM', 'name') - email_claim = getattr(Config, 'OIDC_EMAIL_CLAIM', 'email') - groups_claim = getattr(Config, 'OIDC_GROUPS_CLAIM', 'groups') - - current_app.logger.info("OIDC callback: Looking for claims - username:%s, email:%s, full_name:%s, groups:%s", - username_claim, email_claim, full_name_claim, groups_claim) - - username = (claims.get(username_claim) or userinfo.get(username_claim) or '').strip().lower() - email = (claims.get(email_claim) or userinfo.get(email_claim) or None) + issuer = (claims.get("iss") or userinfo.get("iss") or "").strip() + sub = (claims.get("sub") or userinfo.get("sub") or "").strip() + + username_claim = getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username") + full_name_claim = getattr(Config, "OIDC_FULL_NAME_CLAIM", "name") + email_claim = getattr(Config, "OIDC_EMAIL_CLAIM", "email") + groups_claim = getattr(Config, "OIDC_GROUPS_CLAIM", "groups") + + current_app.logger.info( + "OIDC callback: Looking for claims - username:%s, email:%s, full_name:%s, groups:%s", + username_claim, + email_claim, + full_name_claim, + groups_claim, + ) + + username = (claims.get(username_claim) or userinfo.get(username_claim) or "").strip().lower() + email = claims.get(email_claim) or userinfo.get(email_claim) or None if email: email = email.strip().lower() - full_name = (claims.get(full_name_claim) or userinfo.get(full_name_claim) or None) + full_name = claims.get(full_name_claim) or userinfo.get(full_name_claim) or None if isinstance(full_name, str): full_name = full_name.strip() @@ -517,23 +622,34 @@ def oidc_callback(): if isinstance(groups, str): groups = [groups] - current_app.logger.info("OIDC callback: Extracted values - issuer:%s, sub:%s, username:%s, email:%s, groups:%s", - issuer[:30] if issuer else 'empty', - sub[:20] if sub else 'empty', - username or 'empty', - email or 'empty', - len(groups) if isinstance(groups, list) else 'not-list') + current_app.logger.info( + "OIDC callback: Extracted values - issuer:%s, sub:%s, username:%s, email:%s, groups:%s", + issuer[:30] if issuer else "empty", + sub[:20] if sub else "empty", + username or "empty", + email or "empty", + len(groups) if isinstance(groups, list) else "not-list", + ) if not issuer or not sub: - current_app.logger.error("OIDC callback missing issuer/sub - issuer:'%s' sub:'%s' - ID token parsed:%s, userinfo fetched:%s, claims keys:%s, userinfo keys:%s", - issuer, sub, id_token_parsed, userinfo_fetched, list(claims.keys()), list(userinfo.keys())) - flash(_('Authentication failed: missing issuer or subject claim. Please check OIDC configuration.'), 'error') - return redirect(url_for('auth.login')) + current_app.logger.error( + "OIDC callback missing issuer/sub - issuer:'%s' sub:'%s' - ID token parsed:%s, userinfo fetched:%s, claims keys:%s, userinfo keys:%s", + issuer, + sub, + id_token_parsed, + userinfo_fetched, + list(claims.keys()), + list(userinfo.keys()), + ) + flash( + _("Authentication failed: missing issuer or subject claim. Please check OIDC configuration."), "error" + ) + return redirect(url_for("auth.login")) # Determine a fallback username if not provided if not username: - if email and '@' in email: - username = email.split('@', 1)[0] + if email and "@" in email: + username = email.split("@", 1)[0] else: username = f"user-{sub[-8:]}" @@ -551,31 +667,34 @@ def oidc_callback(): if not user: # Create if allowed if not Config.ALLOW_SELF_REGISTER: - flash(_('User account does not exist and self-registration is disabled.'), 'error') - return redirect(url_for('auth.login')) - role = 'user' + flash(_("User account does not exist and self-registration is disabled."), "error") + return redirect(url_for("auth.login")) + role = "user" try: user = User(username=username, role=role, email=email, full_name=full_name) user.is_active = True user.oidc_issuer = issuer user.oidc_sub = sub db.session.add(user) - if not safe_commit('oidc_create_user', {'username': username, 'email': email}): - raise RuntimeError('db commit failed on user create') - + if not safe_commit("oidc_create_user", {"username": username, "email": email}): + raise RuntimeError("db commit failed on user create") + # Track onboarding started for new OIDC user - track_onboarding_started(user.id, { - "auth_method": "oidc", - "self_registered": True, - "is_admin": role == 'admin', - "has_email": bool(email) - }) - - flash(_('Welcome! Your account has been created.'), 'success') + track_onboarding_started( + user.id, + { + "auth_method": "oidc", + "self_registered": True, + "is_admin": role == "admin", + "has_email": bool(email), + }, + ) + + flash(_("Welcome! Your account has been created."), "success") except Exception as e: current_app.logger.exception("Failed to create user from OIDC claims: %s", e) - flash(_('Could not create your account due to a database error.'), 'error') - return redirect(url_for('auth.login')) + flash(_("Could not create your account due to a database error."), "error") + return redirect(url_for("auth.login")) else: # Update linkage and profile fields changed = False @@ -591,35 +710,35 @@ def oidc_callback(): user.full_name = full_name changed = True if changed: - if not safe_commit('oidc_update_user', {'user_id': user.id}): + if not safe_commit("oidc_update_user", {"user_id": user.id}): current_app.logger.warning("DB commit failed updating user from OIDC; continuing") # Admin role mapping based on configured group or emails try: admin_set = False - admin_group = getattr(Config, 'OIDC_ADMIN_GROUP', None) - admin_emails = getattr(Config, 'OIDC_ADMIN_EMAILS', []) or [] - if admin_group and isinstance(groups, (list, tuple)) and admin_group in groups and user.role != 'admin': - user.role = 'admin' + admin_group = getattr(Config, "OIDC_ADMIN_GROUP", None) + admin_emails = getattr(Config, "OIDC_ADMIN_EMAILS", []) or [] + if admin_group and isinstance(groups, (list, tuple)) and admin_group in groups and user.role != "admin": + user.role = "admin" admin_set = True - if email and email in [e.strip().lower() for e in admin_emails] and user.role != 'admin': - user.role = 'admin' + if email and email in [e.strip().lower() for e in admin_emails] and user.role != "admin": + user.role = "admin" admin_set = True if admin_set: - if not safe_commit('oidc_promote_admin', {'user_id': user.id}): + if not safe_commit("oidc_promote_admin", {"user_id": user.id}): current_app.logger.warning("DB commit failed promoting user to admin from OIDC; continuing") except Exception: pass # Check if user is active if not user.is_active: - flash(_('Account is disabled. Please contact an administrator.'), 'error') - return redirect(url_for('auth.login')) + flash(_("Account is disabled. Please contact an administrator."), "error") + return redirect(url_for("auth.login")) # Persist id_token for possible end-session try: - if isinstance(token, dict) and token.get('id_token'): - session['oidc_id_token'] = token.get('id_token') + if isinstance(token, dict) and token.get("id_token"): + session["oidc_id_token"] = token.get("id_token") except Exception: pass @@ -629,25 +748,25 @@ def oidc_callback(): user.update_last_login() except Exception: pass - + # Track successful OIDC login log_event("auth.login", user_id=user.id, auth_method="oidc") track_event(user.id, "auth.login", {"auth_method": "oidc"}) - + # Identify user with comprehensive segmentation properties identify_user_with_segments(user.id, user) - + # Set super properties (included in all events) set_super_properties(user.id, user) # Redirect to intended page or dashboard - next_page = session.pop('oidc_next', None) or request.args.get('next') - if not next_page or not next_page.startswith('/'): - next_page = url_for('main.dashboard') - flash(_('Welcome back, %(username)s!', username=user.username), 'success') + next_page = session.pop("oidc_next", None) or request.args.get("next") + if not next_page or not next_page.startswith("/"): + next_page = url_for("main.dashboard") + flash(_("Welcome back, %(username)s!", username=user.username), "success") return redirect(next_page) except Exception as e: current_app.logger.exception("OIDC callback error: %s", e) - flash(_('Unexpected error during SSO login. Please try again or contact support.'), 'error') - return redirect(url_for('auth.login')) + flash(_("Unexpected error during SSO login. Please try again or contact support."), "error") + return redirect(url_for("auth.login")) diff --git a/app/routes/budget_alerts.py b/app/routes/budget_alerts.py index ffcf1fb8..6a1c5a67 100644 --- a/app/routes/budget_alerts.py +++ b/app/routes/budget_alerts.py @@ -15,449 +15,428 @@ analyze_resource_allocation, analyze_cost_trends, get_budget_status, - check_budget_alerts + check_budget_alerts, ) from datetime import datetime, timedelta from sqlalchemy import func -budget_alerts_bp = Blueprint('budget_alerts', __name__) +budget_alerts_bp = Blueprint("budget_alerts", __name__) -@budget_alerts_bp.route('/budget/dashboard') +@budget_alerts_bp.route("/budget/dashboard") @login_required def budget_dashboard(): """Budget alerts and forecasting dashboard""" # Get projects with budgets user_project_ids = None if current_user.is_admin: - projects = Project.query.filter( - Project.budget_amount.isnot(None), - Project.status == 'active' - ).order_by(Project.name).all() + projects = ( + Project.query.filter(Project.budget_amount.isnot(None), Project.status == "active") + .order_by(Project.name) + .all() + ) else: # For non-admin users, show only projects they've worked on from sqlalchemy import distinct from app.models import TimeEntry - - user_project_ids_result = db.session.query(distinct(TimeEntry.project_id)).filter( - TimeEntry.user_id == current_user.id - ).all() + + user_project_ids_result = ( + db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() + ) user_project_ids = [pid[0] for pid in user_project_ids_result] - - projects = Project.query.filter( - Project.id.in_(user_project_ids), - Project.budget_amount.isnot(None), - Project.status == 'active' - ).order_by(Project.name).all() - + + projects = ( + Project.query.filter( + Project.id.in_(user_project_ids), Project.budget_amount.isnot(None), Project.status == "active" + ) + .order_by(Project.name) + .all() + ) + # Get budget status for each project project_budgets = [] for project in projects: budget_status = get_budget_status(project.id) if budget_status: project_budgets.append(budget_status) - + # Get active alerts if current_user.is_admin: active_alerts = BudgetAlert.get_active_alerts(acknowledged=False) else: # For non-admin, get alerts for their projects if user_project_ids: - active_alerts = BudgetAlert.query.filter( - BudgetAlert.is_acknowledged == False, - BudgetAlert.project_id.in_(user_project_ids) - ).order_by(BudgetAlert.created_at.desc()).all() + active_alerts = ( + BudgetAlert.query.filter( + BudgetAlert.is_acknowledged == False, BudgetAlert.project_id.in_(user_project_ids) + ) + .order_by(BudgetAlert.created_at.desc()) + .all() + ) else: active_alerts = [] - + # Get alert statistics alert_stats = { - 'total_unacknowledged': len(active_alerts), - 'critical_alerts': len([a for a in active_alerts if a.alert_level == 'critical']), - 'warning_alerts': len([a for a in active_alerts if a.alert_level == 'warning']), + "total_unacknowledged": len(active_alerts), + "critical_alerts": len([a for a in active_alerts if a.alert_level == "critical"]), + "warning_alerts": len([a for a in active_alerts if a.alert_level == "warning"]), } - - log_event('budget_dashboard_viewed', user_id=current_user.id) - - return render_template('budget/dashboard.html', - projects=project_budgets, - active_alerts=active_alerts, - alert_stats=alert_stats) + log_event("budget_dashboard_viewed", user_id=current_user.id) -@budget_alerts_bp.route('/api/budget/burn-rate/') + return render_template( + "budget/dashboard.html", projects=project_budgets, active_alerts=active_alerts, alert_stats=alert_stats + ) + + +@budget_alerts_bp.route("/api/budget/burn-rate/") @login_required def get_burn_rate(project_id): """Get burn rate for a project""" project = Project.query.get_or_404(project_id) - + # Check permissions if not current_user.is_admin: # Check if user has worked on this project from app.models import TimeEntry - has_access = TimeEntry.query.filter_by( - project_id=project_id, - user_id=current_user.id - ).first() is not None - + + has_access = TimeEntry.query.filter_by(project_id=project_id, user_id=current_user.id).first() is not None + if not has_access: - return jsonify({'error': 'Access denied'}), 403 - - days = request.args.get('days', 30, type=int) + return jsonify({"error": "Access denied"}), 403 + + days = request.args.get("days", 30, type=int) burn_rate = calculate_burn_rate(project_id, days) - + if burn_rate is None: - return jsonify({'error': 'Project not found or no data available'}), 404 - - log_event('budget_burn_rate_viewed', user_id=current_user.id, project_id=project_id) - + return jsonify({"error": "Project not found or no data available"}), 404 + + log_event("budget_burn_rate_viewed", user_id=current_user.id, project_id=project_id) + return jsonify(burn_rate) -@budget_alerts_bp.route('/api/budget/completion-estimate/') +@budget_alerts_bp.route("/api/budget/completion-estimate/") @login_required def get_completion_estimate(project_id): """Get estimated completion date for a project""" project = Project.query.get_or_404(project_id) - + # Check permissions if not current_user.is_admin: from app.models import TimeEntry - has_access = TimeEntry.query.filter_by( - project_id=project_id, - user_id=current_user.id - ).first() is not None - + + has_access = TimeEntry.query.filter_by(project_id=project_id, user_id=current_user.id).first() is not None + if not has_access: - return jsonify({'error': 'Access denied'}), 403 - - days = request.args.get('days', 30, type=int) + return jsonify({"error": "Access denied"}), 403 + + days = request.args.get("days", 30, type=int) estimate = estimate_completion_date(project_id, days) - + if estimate is None: - return jsonify({'error': 'Project not found or no budget set'}), 404 - - log_event('budget_completion_estimate_viewed', user_id=current_user.id, project_id=project_id) - + return jsonify({"error": "Project not found or no budget set"}), 404 + + log_event("budget_completion_estimate_viewed", user_id=current_user.id, project_id=project_id) + return jsonify(estimate) -@budget_alerts_bp.route('/api/budget/resource-allocation/') +@budget_alerts_bp.route("/api/budget/resource-allocation/") @login_required def get_resource_allocation(project_id): """Get resource allocation analysis for a project""" project = Project.query.get_or_404(project_id) - + # Check permissions if not current_user.is_admin: from app.models import TimeEntry - has_access = TimeEntry.query.filter_by( - project_id=project_id, - user_id=current_user.id - ).first() is not None - + + has_access = TimeEntry.query.filter_by(project_id=project_id, user_id=current_user.id).first() is not None + if not has_access: - return jsonify({'error': 'Access denied'}), 403 - - days = request.args.get('days', 30, type=int) + return jsonify({"error": "Access denied"}), 403 + + days = request.args.get("days", 30, type=int) allocation = analyze_resource_allocation(project_id, days) - + if allocation is None: - return jsonify({'error': 'Project not found'}), 404 - - log_event('budget_resource_allocation_viewed', user_id=current_user.id, project_id=project_id) - + return jsonify({"error": "Project not found"}), 404 + + log_event("budget_resource_allocation_viewed", user_id=current_user.id, project_id=project_id) + return jsonify(allocation) -@budget_alerts_bp.route('/api/budget/cost-trends/') +@budget_alerts_bp.route("/api/budget/cost-trends/") @login_required def get_cost_trends(project_id): """Get cost trend analysis for a project""" project = Project.query.get_or_404(project_id) - + # Check permissions if not current_user.is_admin: from app.models import TimeEntry - has_access = TimeEntry.query.filter_by( - project_id=project_id, - user_id=current_user.id - ).first() is not None - + + has_access = TimeEntry.query.filter_by(project_id=project_id, user_id=current_user.id).first() is not None + if not has_access: - return jsonify({'error': 'Access denied'}), 403 - - days = request.args.get('days', 90, type=int) - granularity = request.args.get('granularity', 'week') - - if granularity not in ['day', 'week', 'month']: - return jsonify({'error': 'Invalid granularity. Use day, week, or month'}), 400 - + return jsonify({"error": "Access denied"}), 403 + + days = request.args.get("days", 90, type=int) + granularity = request.args.get("granularity", "week") + + if granularity not in ["day", "week", "month"]: + return jsonify({"error": "Invalid granularity. Use day, week, or month"}), 400 + trends = analyze_cost_trends(project_id, days, granularity) - + if trends is None: - return jsonify({'error': 'Project not found'}), 404 - - log_event('budget_cost_trends_viewed', user_id=current_user.id, project_id=project_id) - + return jsonify({"error": "Project not found"}), 404 + + log_event("budget_cost_trends_viewed", user_id=current_user.id, project_id=project_id) + return jsonify(trends) -@budget_alerts_bp.route('/api/budget/status/') +@budget_alerts_bp.route("/api/budget/status/") @login_required def get_project_budget_status(project_id): """Get comprehensive budget status for a project""" project = Project.query.get_or_404(project_id) - + # Check permissions if not current_user.is_admin: from app.models import TimeEntry - has_access = TimeEntry.query.filter_by( - project_id=project_id, - user_id=current_user.id - ).first() is not None - + + has_access = TimeEntry.query.filter_by(project_id=project_id, user_id=current_user.id).first() is not None + if not has_access: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + budget_status = get_budget_status(project_id) - + if budget_status is None: - return jsonify({'error': 'Project not found or no budget set'}), 404 - + return jsonify({"error": "Project not found or no budget set"}), 404 + return jsonify(budget_status) -@budget_alerts_bp.route('/api/budget/alerts') +@budget_alerts_bp.route("/api/budget/alerts") @login_required def get_alerts(): """Get budget alerts""" - project_id = request.args.get('project_id', type=int) - acknowledged = request.args.get('acknowledged', 'false').lower() == 'true' - + project_id = request.args.get("project_id", type=int) + acknowledged = request.args.get("acknowledged", "false").lower() == "true" + if current_user.is_admin: alerts = BudgetAlert.get_active_alerts(project_id=project_id, acknowledged=acknowledged) else: # For non-admin, get alerts for their projects from sqlalchemy import distinct from app.models import TimeEntry - - user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter( - TimeEntry.user_id == current_user.id - ).all() + + user_project_ids = ( + db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() + ) user_project_ids = [pid[0] for pid in user_project_ids] - + query = BudgetAlert.query.filter( - BudgetAlert.is_acknowledged == acknowledged, - BudgetAlert.project_id.in_(user_project_ids) + BudgetAlert.is_acknowledged == acknowledged, BudgetAlert.project_id.in_(user_project_ids) ) - + if project_id: query = query.filter_by(project_id=project_id) - + alerts = query.order_by(BudgetAlert.created_at.desc()).all() - - return jsonify({ - 'alerts': [alert.to_dict() for alert in alerts], - 'count': len(alerts) - }) + + return jsonify({"alerts": [alert.to_dict() for alert in alerts], "count": len(alerts)}) -@budget_alerts_bp.route('/api/budget/alerts//acknowledge', methods=['POST']) +@budget_alerts_bp.route("/api/budget/alerts//acknowledge", methods=["POST"]) @login_required def acknowledge_alert(alert_id): """Acknowledge a budget alert""" alert = BudgetAlert.query.get_or_404(alert_id) - + # Check permissions if not current_user.is_admin: from app.models import TimeEntry - has_access = TimeEntry.query.filter_by( - project_id=alert.project_id, - user_id=current_user.id - ).first() is not None - + + has_access = TimeEntry.query.filter_by(project_id=alert.project_id, user_id=current_user.id).first() is not None + if not has_access: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + if alert.is_acknowledged: - return jsonify({'message': 'Alert already acknowledged'}), 200 - + return jsonify({"message": "Alert already acknowledged"}), 200 + alert.acknowledge(current_user.id) - - log_event('budget_alert_acknowledged', user_id=current_user.id, - alert_id=alert_id, project_id=alert.project_id) - - return jsonify({ - 'message': 'Alert acknowledged successfully', - 'alert': alert.to_dict() - }) + log_event("budget_alert_acknowledged", user_id=current_user.id, alert_id=alert_id, project_id=alert.project_id) + + return jsonify({"message": "Alert acknowledged successfully", "alert": alert.to_dict()}) -@budget_alerts_bp.route('/api/budget/check-alerts/', methods=['POST']) + +@budget_alerts_bp.route("/api/budget/check-alerts/", methods=["POST"]) @login_required def check_project_alerts(project_id): """Manually check and create alerts for a project (admin only)""" if not current_user.is_admin: - return jsonify({'error': 'Admin access required'}), 403 - + return jsonify({"error": "Admin access required"}), 403 + project = Project.query.get_or_404(project_id) - + alerts_to_create = check_budget_alerts(project_id) - + created_alerts = [] for alert_data in alerts_to_create: alert = BudgetAlert.create_alert( - project_id=alert_data['project_id'], - alert_type=alert_data['type'], - budget_consumed_percent=alert_data['budget_consumed_percent'], - budget_amount=alert_data['budget_amount'], - consumed_amount=alert_data['consumed_amount'] + project_id=alert_data["project_id"], + alert_type=alert_data["type"], + budget_consumed_percent=alert_data["budget_consumed_percent"], + budget_amount=alert_data["budget_amount"], + consumed_amount=alert_data["consumed_amount"], ) created_alerts.append(alert.to_dict()) - - log_event('budget_alerts_checked', user_id=current_user.id, project_id=project_id) - - return jsonify({ - 'message': f'Checked alerts for project {project.name}', - 'alerts_created': len(created_alerts), - 'alerts': created_alerts - }) + + log_event("budget_alerts_checked", user_id=current_user.id, project_id=project_id) + + return jsonify( + { + "message": f"Checked alerts for project {project.name}", + "alerts_created": len(created_alerts), + "alerts": created_alerts, + } + ) -@budget_alerts_bp.route('/budget/project/') +@budget_alerts_bp.route("/budget/project/") @login_required def project_budget_detail(project_id): """Detailed budget view for a specific project""" project = Project.query.get_or_404(project_id) - + # Check permissions if not current_user.is_admin: from app.models import TimeEntry - has_access = TimeEntry.query.filter_by( - project_id=project_id, - user_id=current_user.id - ).first() is not None - + + has_access = TimeEntry.query.filter_by(project_id=project_id, user_id=current_user.id).first() is not None + if not has_access: - flash(_('You do not have access to this project.'), 'error') - return redirect(url_for('budget_alerts.budget_dashboard')) - + flash(_("You do not have access to this project."), "error") + return redirect(url_for("budget_alerts.budget_dashboard")) + # Get budget status budget_status = get_budget_status(project_id) - + if not budget_status: - flash(_('This project does not have a budget set.'), 'warning') - return redirect(url_for('budget_alerts.budget_dashboard')) - + flash(_("This project does not have a budget set."), "warning") + return redirect(url_for("budget_alerts.budget_dashboard")) + # Get burn rate burn_rate = calculate_burn_rate(project_id, 30) - + # Get completion estimate completion_estimate = estimate_completion_date(project_id, 30) - + # Get resource allocation resource_allocation = analyze_resource_allocation(project_id, 30) - + # Get cost trends - cost_trends = analyze_cost_trends(project_id, 90, 'week') - + cost_trends = analyze_cost_trends(project_id, 90, "week") + # Get alerts for this project - alerts = BudgetAlert.query.filter_by( - project_id=project_id, - is_acknowledged=False - ).order_by(BudgetAlert.created_at.desc()).all() - - log_event('project_budget_detail_viewed', user_id=current_user.id, project_id=project_id) - - return render_template('budget/project_detail.html', - project=project, - budget_status=budget_status, - burn_rate=burn_rate, - completion_estimate=completion_estimate, - resource_allocation=resource_allocation, - cost_trends=cost_trends, - alerts=alerts) - - -@budget_alerts_bp.route('/api/budget/summary') + alerts = ( + BudgetAlert.query.filter_by(project_id=project_id, is_acknowledged=False) + .order_by(BudgetAlert.created_at.desc()) + .all() + ) + + log_event("project_budget_detail_viewed", user_id=current_user.id, project_id=project_id) + + return render_template( + "budget/project_detail.html", + project=project, + budget_status=budget_status, + burn_rate=burn_rate, + completion_estimate=completion_estimate, + resource_allocation=resource_allocation, + cost_trends=cost_trends, + alerts=alerts, + ) + + +@budget_alerts_bp.route("/api/budget/summary") @login_required def get_budget_summary(): """Get summary of all budget alerts and project statuses""" if current_user.is_admin: - projects = Project.query.filter( - Project.budget_amount.isnot(None), - Project.status == 'active' - ).all() + projects = Project.query.filter(Project.budget_amount.isnot(None), Project.status == "active").all() else: # For non-admin, get projects they've worked on from sqlalchemy import distinct from app.models import TimeEntry - - user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter( - TimeEntry.user_id == current_user.id - ).all() + + user_project_ids = ( + db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() + ) user_project_ids = [pid[0] for pid in user_project_ids] - + projects = Project.query.filter( - Project.id.in_(user_project_ids), - Project.budget_amount.isnot(None), - Project.status == 'active' + Project.id.in_(user_project_ids), Project.budget_amount.isnot(None), Project.status == "active" ).all() - + summary = { - 'total_projects': len(projects), - 'healthy': 0, - 'warning': 0, - 'critical': 0, - 'over_budget': 0, - 'total_budget': 0, - 'total_consumed': 0, - 'projects': [] + "total_projects": len(projects), + "healthy": 0, + "warning": 0, + "critical": 0, + "over_budget": 0, + "total_budget": 0, + "total_consumed": 0, + "projects": [], } - + for project in projects: budget_status = get_budget_status(project.id) if budget_status: - summary['total_budget'] += budget_status['budget_amount'] - summary['total_consumed'] += budget_status['consumed_amount'] - summary[budget_status['status']] += 1 - summary['projects'].append(budget_status) - + summary["total_budget"] += budget_status["budget_amount"] + summary["total_consumed"] += budget_status["consumed_amount"] + summary[budget_status["status"]] += 1 + summary["projects"].append(budget_status) + # Get alert statistics if current_user.is_admin: alert_stats = BudgetAlert.get_alert_summary() else: from sqlalchemy import distinct from app.models import TimeEntry - - user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter( - TimeEntry.user_id == current_user.id - ).all() + + user_project_ids = ( + db.session.query(distinct(TimeEntry.project_id)).filter(TimeEntry.user_id == current_user.id).all() + ) user_project_ids = [pid[0] for pid in user_project_ids] - - total_alerts = BudgetAlert.query.filter( - BudgetAlert.project_id.in_(user_project_ids) - ).count() - + + total_alerts = BudgetAlert.query.filter(BudgetAlert.project_id.in_(user_project_ids)).count() + unacknowledged_alerts = BudgetAlert.query.filter( - BudgetAlert.project_id.in_(user_project_ids), - BudgetAlert.is_acknowledged == False + BudgetAlert.project_id.in_(user_project_ids), BudgetAlert.is_acknowledged == False ).count() - + critical_alerts = BudgetAlert.query.filter( BudgetAlert.project_id.in_(user_project_ids), - BudgetAlert.alert_level == 'critical', - BudgetAlert.is_acknowledged == False + BudgetAlert.alert_level == "critical", + BudgetAlert.is_acknowledged == False, ).count() - + alert_stats = { - 'total_alerts': total_alerts, - 'unacknowledged_alerts': unacknowledged_alerts, - 'critical_alerts': critical_alerts + "total_alerts": total_alerts, + "unacknowledged_alerts": unacknowledged_alerts, + "critical_alerts": critical_alerts, } - - summary['alert_stats'] = alert_stats - - return jsonify(summary) + summary["alert_stats"] = alert_stats + + return jsonify(summary) diff --git a/app/routes/calendar.py b/app/routes/calendar.py index 29f8f5e5..0a4e0360 100644 --- a/app/routes/calendar.py +++ b/app/routes/calendar.py @@ -10,47 +10,43 @@ from app.utils.permissions import check_permission import os -calendar_bp = Blueprint('calendar', __name__) +calendar_bp = Blueprint("calendar", __name__) -@calendar_bp.route('/calendar') +@calendar_bp.route("/calendar") @login_required def view_calendar(): """Display the calendar view with events, tasks, and time entries""" - view_type = request.args.get('view', 'month') # day, week, month - date_str = request.args.get('date', '') - + view_type = request.args.get("view", "month") # day, week, month + date_str = request.args.get("date", "") + # Parse the date or use today if date_str: try: - current_date = datetime.strptime(date_str, '%Y-%m-%d') + current_date = datetime.strptime(date_str, "%Y-%m-%d") except ValueError: current_date = now_in_app_timezone() else: current_date = now_in_app_timezone() - + # Get projects and clients for event creation - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.query.filter_by(is_active=True).order_by(Client.name).all() - + return render_template( - 'calendar/view.html', - view_type=view_type, - current_date=current_date, - projects=projects, - clients=clients + "calendar/view.html", view_type=view_type, current_date=current_date, projects=projects, clients=clients ) -@calendar_bp.route('/api/calendar/events') +@calendar_bp.route("/api/calendar/events") @login_required def get_events(): """API endpoint to fetch calendar events for a date range""" - start_str = request.args.get('start') - end_str = request.args.get('end') - include_tasks = request.args.get('include_tasks', 'true').lower() == 'true' - include_time_entries = request.args.get('include_time_entries', 'true').lower() == 'true' - + start_str = request.args.get("start") + end_str = request.args.get("end") + include_tasks = request.args.get("include_tasks", "true").lower() == "true" + include_time_entries = request.args.get("include_time_entries", "true").lower() == "true" + print(f"\n{'='*80}") print(f"API ENDPOINT CALLED - /api/calendar/events") print(f" include_tasks query param: {request.args.get('include_tasks')}") @@ -58,16 +54,16 @@ def get_events(): print(f" include_tasks parsed: {include_tasks}") print(f" include_time_entries parsed: {include_time_entries}") print(f"{'='*80}\n") - + if not start_str or not end_str: - return jsonify({'error': 'Start and end dates are required'}), 400 - + return jsonify({"error": "Start and end dates are required"}), 400 + try: - start_date = datetime.fromisoformat(start_str.replace('Z', '+00:00')) - end_date = datetime.fromisoformat(end_str.replace('Z', '+00:00')) + start_date = datetime.fromisoformat(start_str.replace("Z", "+00:00")) + end_date = datetime.fromisoformat(end_str.replace("Z", "+00:00")) except (ValueError, AttributeError): - return jsonify({'error': 'Invalid date format'}), 400 - + return jsonify({"error": "Invalid date format"}), 400 + print(f"\n{'='*80}") print(f"ROUTE HANDLER - get_events API:") print(f" user_id={current_user.id}") @@ -76,389 +72,372 @@ def get_events(): print(f" include_tasks={include_tasks} (type: {type(include_tasks)})") print(f" include_time_entries={include_time_entries} (type: {type(include_time_entries)})") print(f"{'='*80}\n") - + # Get events using the model's static method result = CalendarEvent.get_events_in_range( user_id=current_user.id, start_date=start_date, end_date=end_date, include_tasks=include_tasks, - include_time_entries=include_time_entries + include_time_entries=include_time_entries, ) - + print(f"\n{'='*80}") print(f"ROUTE HANDLER - Result from get_events_in_range:") print(f" events count: {len(result.get('events', []))}") print(f" tasks count: {len(result.get('tasks', []))}") print(f" time_entries count: {len(result.get('time_entries', []))}") print(f"{'='*80}\n") - + # Add debug marker to verify this code is running - result['_debug_timestamp'] = datetime.now().isoformat() - result['_debug_version'] = 'v3_no_cache' - + result["_debug_timestamp"] = datetime.now().isoformat() + result["_debug_version"] = "v3_no_cache" + response = jsonify(result) - response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" return response -@calendar_bp.route('/api/calendar/events', methods=['POST']) +@calendar_bp.route("/api/calendar/events", methods=["POST"]) @login_required def create_event(): """Create a new calendar event""" data = request.get_json() - + if not data: - return jsonify({'error': 'No data provided'}), 400 - + return jsonify({"error": "No data provided"}), 400 + # Validate required fields - required_fields = ['title', 'start', 'end'] + required_fields = ["title", "start", "end"] for field in required_fields: if field not in data: - return jsonify({'error': f'Missing required field: {field}'}), 400 - + return jsonify({"error": f"Missing required field: {field}"}), 400 + try: # Parse dates - start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00')) - end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00')) - + start_time = datetime.fromisoformat(data["start"].replace("Z", "+00:00")) + end_time = datetime.fromisoformat(data["end"].replace("Z", "+00:00")) + # Create event event = CalendarEvent( user_id=current_user.id, - title=data['title'], + title=data["title"], start_time=start_time, end_time=end_time, - description=data.get('description'), - all_day=data.get('allDay', False), - location=data.get('location'), - event_type=data.get('eventType', 'event'), - project_id=data.get('projectId'), - task_id=data.get('taskId'), - client_id=data.get('clientId'), - is_recurring=data.get('isRecurring', False), - recurrence_rule=data.get('recurrenceRule'), - recurrence_end_date=datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data.get('recurrenceEndDate') else None, - reminder_minutes=data.get('reminderMinutes'), - color=data.get('color'), - is_private=data.get('isPrivate', False) + description=data.get("description"), + all_day=data.get("allDay", False), + location=data.get("location"), + event_type=data.get("eventType", "event"), + project_id=data.get("projectId"), + task_id=data.get("taskId"), + client_id=data.get("clientId"), + is_recurring=data.get("isRecurring", False), + recurrence_rule=data.get("recurrenceRule"), + recurrence_end_date=( + datetime.fromisoformat(data["recurrenceEndDate"].replace("Z", "+00:00")) + if data.get("recurrenceEndDate") + else None + ), + reminder_minutes=data.get("reminderMinutes"), + color=data.get("color"), + is_private=data.get("isPrivate", False), ) - + db.session.add(event) if not safe_commit(): - return jsonify({'error': 'Failed to create event'}), 500 - - return jsonify({ - 'success': True, - 'event': event.to_dict(), - 'message': _('Event created successfully') - }), 201 - + return jsonify({"error": "Failed to create event"}), 500 + + return jsonify({"success": True, "event": event.to_dict(), "message": _("Event created successfully")}), 201 + except (ValueError, AttributeError) as e: - return jsonify({'error': f'Invalid data: {str(e)}'}), 400 + return jsonify({"error": f"Invalid data: {str(e)}"}), 400 except Exception as e: db.session.rollback() - return jsonify({'error': f'Error creating event: {str(e)}'}), 500 + return jsonify({"error": f"Error creating event: {str(e)}"}), 500 -@calendar_bp.route('/api/calendar/events/', methods=['GET']) +@calendar_bp.route("/api/calendar/events/", methods=["GET"]) @login_required def get_event(event_id): """Get a specific calendar event""" event = CalendarEvent.query.get_or_404(event_id) - + # Check if user has permission to view this event if event.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + return jsonify(event.to_dict()) -@calendar_bp.route('/api/calendar/events/', methods=['PUT']) +@calendar_bp.route("/api/calendar/events/", methods=["PUT"]) @login_required def update_event(event_id): """Update a calendar event""" event = CalendarEvent.query.get_or_404(event_id) - + # Check if user has permission to edit this event if event.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + data = request.get_json() if not data: - return jsonify({'error': 'No data provided'}), 400 - + return jsonify({"error": "No data provided"}), 400 + try: # Update fields - if 'title' in data: - event.title = data['title'] - if 'description' in data: - event.description = data['description'] - if 'start' in data: - event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00')) - if 'end' in data: - event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00')) - if 'allDay' in data: - event.all_day = data['allDay'] - if 'location' in data: - event.location = data['location'] - if 'eventType' in data: - event.event_type = data['eventType'] - if 'projectId' in data: - event.project_id = data['projectId'] - if 'taskId' in data: - event.task_id = data['taskId'] - if 'clientId' in data: - event.client_id = data['clientId'] - if 'isRecurring' in data: - event.is_recurring = data['isRecurring'] - if 'recurrenceRule' in data: - event.recurrence_rule = data['recurrenceRule'] - if 'recurrenceEndDate' in data: - event.recurrence_end_date = datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data['recurrenceEndDate'] else None - if 'reminderMinutes' in data: - event.reminder_minutes = data['reminderMinutes'] - if 'color' in data: - event.color = data['color'] - if 'isPrivate' in data: - event.is_private = data['isPrivate'] - + if "title" in data: + event.title = data["title"] + if "description" in data: + event.description = data["description"] + if "start" in data: + event.start_time = datetime.fromisoformat(data["start"].replace("Z", "+00:00")) + if "end" in data: + event.end_time = datetime.fromisoformat(data["end"].replace("Z", "+00:00")) + if "allDay" in data: + event.all_day = data["allDay"] + if "location" in data: + event.location = data["location"] + if "eventType" in data: + event.event_type = data["eventType"] + if "projectId" in data: + event.project_id = data["projectId"] + if "taskId" in data: + event.task_id = data["taskId"] + if "clientId" in data: + event.client_id = data["clientId"] + if "isRecurring" in data: + event.is_recurring = data["isRecurring"] + if "recurrenceRule" in data: + event.recurrence_rule = data["recurrenceRule"] + if "recurrenceEndDate" in data: + event.recurrence_end_date = ( + datetime.fromisoformat(data["recurrenceEndDate"].replace("Z", "+00:00")) + if data["recurrenceEndDate"] + else None + ) + if "reminderMinutes" in data: + event.reminder_minutes = data["reminderMinutes"] + if "color" in data: + event.color = data["color"] + if "isPrivate" in data: + event.is_private = data["isPrivate"] + event.updated_at = now_in_app_timezone() - + if not safe_commit(): - return jsonify({'error': 'Failed to update event'}), 500 - - return jsonify({ - 'success': True, - 'event': event.to_dict(), - 'message': _('Event updated successfully') - }) - + return jsonify({"error": "Failed to update event"}), 500 + + return jsonify({"success": True, "event": event.to_dict(), "message": _("Event updated successfully")}) + except (ValueError, AttributeError) as e: - return jsonify({'error': f'Invalid data: {str(e)}'}), 400 + return jsonify({"error": f"Invalid data: {str(e)}"}), 400 except Exception as e: db.session.rollback() - return jsonify({'error': f'Error updating event: {str(e)}'}), 500 + return jsonify({"error": f"Error updating event: {str(e)}"}), 500 -@calendar_bp.route('/api/calendar/events/', methods=['DELETE', 'POST']) +@calendar_bp.route("/api/calendar/events/", methods=["DELETE", "POST"]) @login_required def delete_event(event_id): """Delete a calendar event""" event = CalendarEvent.query.get_or_404(event_id) - + # Check if user has permission to delete this event if event.user_id != current_user.id and not current_user.is_admin: - if request.method == 'POST': - flash(_('You do not have permission to delete this event.'), 'error') - return redirect(url_for('calendar.view_calendar')) - return jsonify({'error': 'Permission denied'}), 403 - + if request.method == "POST": + flash(_("You do not have permission to delete this event."), "error") + return redirect(url_for("calendar.view_calendar")) + return jsonify({"error": "Permission denied"}), 403 + try: db.session.delete(event) if not safe_commit(): - if request.method == 'POST': - flash(_('Failed to delete event'), 'error') - return redirect(url_for('calendar.view_calendar')) - return jsonify({'error': 'Failed to delete event'}), 500 - - if request.method == 'POST': - flash(_('Event deleted successfully'), 'success') - return redirect(url_for('calendar.view_calendar')) - - return jsonify({ - 'success': True, - 'message': _('Event deleted successfully') - }) - + if request.method == "POST": + flash(_("Failed to delete event"), "error") + return redirect(url_for("calendar.view_calendar")) + return jsonify({"error": "Failed to delete event"}), 500 + + if request.method == "POST": + flash(_("Event deleted successfully"), "success") + return redirect(url_for("calendar.view_calendar")) + + return jsonify({"success": True, "message": _("Event deleted successfully")}) + except Exception as e: db.session.rollback() - if request.method == 'POST': - flash(_('Error deleting event: %(error)s', error=str(e)), 'error') - return redirect(url_for('calendar.view_calendar')) - return jsonify({'error': f'Error deleting event: {str(e)}'}), 500 + if request.method == "POST": + flash(_("Error deleting event: %(error)s", error=str(e)), "error") + return redirect(url_for("calendar.view_calendar")) + return jsonify({"error": f"Error deleting event: {str(e)}"}), 500 -@calendar_bp.route('/api/calendar/events//move', methods=['POST']) +@calendar_bp.route("/api/calendar/events//move", methods=["POST"]) @login_required def move_event(event_id): """Move an event to a new time (drag and drop support)""" event = CalendarEvent.query.get_or_404(event_id) - + # Check if user has permission to edit this event if event.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + data = request.get_json() - if not data or 'start' not in data or 'end' not in data: - return jsonify({'error': 'Start and end times are required'}), 400 - + if not data or "start" not in data or "end" not in data: + return jsonify({"error": "Start and end times are required"}), 400 + try: - event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00')) - event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00')) + event.start_time = datetime.fromisoformat(data["start"].replace("Z", "+00:00")) + event.end_time = datetime.fromisoformat(data["end"].replace("Z", "+00:00")) event.updated_at = now_in_app_timezone() - + if not safe_commit(): - return jsonify({'error': 'Failed to move event'}), 500 - - return jsonify({ - 'success': True, - 'event': event.to_dict(), - 'message': _('Event moved successfully') - }) - + return jsonify({"error": "Failed to move event"}), 500 + + return jsonify({"success": True, "event": event.to_dict(), "message": _("Event moved successfully")}) + except (ValueError, AttributeError) as e: - return jsonify({'error': f'Invalid data: {str(e)}'}), 400 + return jsonify({"error": f"Invalid data: {str(e)}"}), 400 except Exception as e: db.session.rollback() - return jsonify({'error': f'Error moving event: {str(e)}'}), 500 + return jsonify({"error": f"Error moving event: {str(e)}"}), 500 -@calendar_bp.route('/api/calendar/events//resize', methods=['POST']) +@calendar_bp.route("/api/calendar/events//resize", methods=["POST"]) @login_required def resize_event(event_id): """Resize an event (change duration)""" event = CalendarEvent.query.get_or_404(event_id) - + # Check if user has permission to edit this event if event.user_id != current_user.id and not current_user.is_admin: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + data = request.get_json() if not data: - return jsonify({'error': 'No data provided'}), 400 - + return jsonify({"error": "No data provided"}), 400 + try: - if 'end' in data: - event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00')) - elif 'start' in data: - event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00')) - + if "end" in data: + event.end_time = datetime.fromisoformat(data["end"].replace("Z", "+00:00")) + elif "start" in data: + event.start_time = datetime.fromisoformat(data["start"].replace("Z", "+00:00")) + event.updated_at = now_in_app_timezone() - + if not safe_commit(): - return jsonify({'error': 'Failed to resize event'}), 500 - - return jsonify({ - 'success': True, - 'event': event.to_dict(), - 'message': _('Event resized successfully') - }) - + return jsonify({"error": "Failed to resize event"}), 500 + + return jsonify({"success": True, "event": event.to_dict(), "message": _("Event resized successfully")}) + except (ValueError, AttributeError) as e: - return jsonify({'error': f'Invalid data: {str(e)}'}), 400 + return jsonify({"error": f"Invalid data: {str(e)}"}), 400 except Exception as e: db.session.rollback() - return jsonify({'error': f'Error resizing event: {str(e)}'}), 500 + return jsonify({"error": f"Error resizing event: {str(e)}"}), 500 -@calendar_bp.route('/calendar/event/') +@calendar_bp.route("/calendar/event/") @login_required def view_event(event_id): """View event details page""" event = CalendarEvent.query.get_or_404(event_id) - + # Check if user has permission to view this event if event.user_id != current_user.id and not current_user.is_admin: - flash(_('You do not have permission to view this event.'), 'error') - return redirect(url_for('calendar.view_calendar')) - - return render_template('calendar/event_detail.html', event=event) + flash(_("You do not have permission to view this event."), "error") + return redirect(url_for("calendar.view_calendar")) + return render_template("calendar/event_detail.html", event=event) -@calendar_bp.route('/calendar/event/new') + +@calendar_bp.route("/calendar/event/new") @login_required def new_event(): """Create new event form""" - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.query.filter_by(is_active=True).order_by(Client.name).all() - tasks = Task.query.filter_by(assigned_to=current_user.id, status='in_progress').order_by(Task.name).all() - + tasks = Task.query.filter_by(assigned_to=current_user.id, status="in_progress").order_by(Task.name).all() + # Get date from query params if provided - date_str = request.args.get('date') - time_str = request.args.get('time') - + date_str = request.args.get("date") + time_str = request.args.get("time") + initial_date = None initial_time = None - + if date_str: try: - initial_date = datetime.strptime(date_str, '%Y-%m-%d').date() + initial_date = datetime.strptime(date_str, "%Y-%m-%d").date() except ValueError: pass - + if time_str: try: - initial_time = datetime.strptime(time_str, '%H:%M').time() + initial_time = datetime.strptime(time_str, "%H:%M").time() except ValueError: pass - + return render_template( - 'calendar/event_form.html', + "calendar/event_form.html", projects=projects, clients=clients, tasks=tasks, initial_date=initial_date, - initial_time=initial_time + initial_time=initial_time, ) -@calendar_bp.route('/calendar/event//edit') +@calendar_bp.route("/calendar/event//edit") @login_required def edit_event(event_id): """Edit event form""" event = CalendarEvent.query.get_or_404(event_id) - + # Check if user has permission to edit this event if event.user_id != current_user.id and not current_user.is_admin: - flash(_('You do not have permission to edit this event.'), 'error') - return redirect(url_for('calendar.view_calendar')) - - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + flash(_("You do not have permission to edit this event."), "error") + return redirect(url_for("calendar.view_calendar")) + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.query.filter_by(is_active=True).order_by(Client.name).all() tasks = Task.query.filter_by(assigned_to=current_user.id).order_by(Task.name).all() - + return render_template( - 'calendar/event_form.html', - event=event, - projects=projects, - clients=clients, - tasks=tasks, - edit_mode=True + "calendar/event_form.html", event=event, projects=projects, clients=clients, tasks=tasks, edit_mode=True ) -@calendar_bp.route('/calendar/integrations') +@calendar_bp.route("/calendar/integrations") @login_required def list_integrations(): """List calendar integrations""" service = CalendarIntegrationService() integrations = service.get_user_integrations(current_user.id) - return render_template('calendar/integrations.html', integrations=integrations) + return render_template("calendar/integrations.html", integrations=integrations) -@calendar_bp.route('/calendar/integrations/google/connect') +@calendar_bp.route("/calendar/integrations/google/connect") @login_required def connect_google(): """Connect Google Calendar""" # This would initiate OAuth flow # For now, return a placeholder - flash(_('Google Calendar integration coming soon.'), 'info') - return redirect(url_for('calendar.list_integrations')) + flash(_("Google Calendar integration coming soon."), "info") + return redirect(url_for("calendar.list_integrations")) -@calendar_bp.route('/calendar/integrations//disconnect', methods=['POST']) +@calendar_bp.route("/calendar/integrations//disconnect", methods=["POST"]) @login_required def disconnect_integration(integration_id): """Disconnect a calendar integration""" service = CalendarIntegrationService() result = service.deactivate_integration(integration_id, current_user.id) - - if result['success']: - flash(_('Calendar integration disconnected successfully.'), 'success') + + if result["success"]: + flash(_("Calendar integration disconnected successfully."), "success") else: - flash(result['message'], 'error') - - return redirect(url_for('calendar.list_integrations')) + flash(result["message"], "error") + return redirect(url_for("calendar.list_integrations")) diff --git a/app/routes/client_notes.py b/app/routes/client_notes.py index c0449c3e..60a9b7ab 100644 --- a/app/routes/client_notes.py +++ b/app/routes/client_notes.py @@ -5,268 +5,248 @@ from app.models import ClientNote, Client from app.utils.db import safe_commit -client_notes_bp = Blueprint('client_notes', __name__) +client_notes_bp = Blueprint("client_notes", __name__) -@client_notes_bp.route('/clients//notes/create', methods=['POST']) + +@client_notes_bp.route("/clients//notes/create", methods=["POST"]) @login_required def create_note(client_id): """Create a new note for a client""" # Verify client exists first (before try block to let 404 abort properly) client = Client.query.get_or_404(client_id) - + try: - content = request.form.get('content', '').strip() - is_important = request.form.get('is_important', 'false').lower() == 'true' - + content = request.form.get("content", "").strip() + is_important = request.form.get("is_important", "false").lower() == "true" + # Validation if not content: - flash(_('Note content cannot be empty'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + flash(_("Note content cannot be empty"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + # Create the note - note = ClientNote( - content=content, - user_id=current_user.id, - client_id=client_id, - is_important=is_important - ) - + note = ClientNote(content=content, user_id=current_user.id, client_id=client_id, is_important=is_important) + db.session.add(note) - if safe_commit('create_client_note', {'client_id': client_id}): + if safe_commit("create_client_note", {"client_id": client_id}): # Log note creation - log_event("client_note.created", - user_id=current_user.id, - client_note_id=note.id, - client_id=client_id) - track_event(current_user.id, "client_note.created", { - "note_id": note.id, - "client_id": client_id - }) - flash(_('Note added successfully'), 'success') + log_event("client_note.created", user_id=current_user.id, client_note_id=note.id, client_id=client_id) + track_event(current_user.id, "client_note.created", {"note_id": note.id, "client_id": client_id}) + flash(_("Note added successfully"), "success") else: - flash(_('Error adding note'), 'error') - + flash(_("Error adding note"), "error") + except ValueError as e: - flash(_('Error adding note: %(error)s', error=str(e)), 'error') + flash(_("Error adding note: %(error)s", error=str(e)), "error") except Exception as e: - flash(_('Error adding note: %(error)s', error=str(e)), 'error') - + flash(_("Error adding note: %(error)s", error=str(e)), "error") + # Redirect back to the client page - return redirect(url_for('clients.view_client', client_id=client_id)) + return redirect(url_for("clients.view_client", client_id=client_id)) + -@client_notes_bp.route('/clients//notes//edit', methods=['GET', 'POST']) +@client_notes_bp.route("/clients//notes//edit", methods=["GET", "POST"]) @login_required def edit_note(client_id, note_id): """Edit an existing client note""" note = ClientNote.query.get_or_404(note_id) - + # Verify note belongs to this client if note.client_id != client_id: - flash(_('Note does not belong to this client'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + flash(_("Note does not belong to this client"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + # Check permissions if not note.can_edit(current_user): - flash(_('You do not have permission to edit this note'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - - if request.method == 'POST': + flash(_("You do not have permission to edit this note"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + + if request.method == "POST": try: - content = request.form.get('content', '').strip() - is_important = request.form.get('is_important', 'false').lower() == 'true' - + content = request.form.get("content", "").strip() + is_important = request.form.get("is_important", "false").lower() == "true" + if not content: - flash(_('Note content cannot be empty'), 'error') - return render_template('client_notes/edit.html', note=note, client_id=client_id) - + flash(_("Note content cannot be empty"), "error") + return render_template("client_notes/edit.html", note=note, client_id=client_id) + note.edit_content(content, current_user, is_important=is_important) - - if not safe_commit('edit_client_note', {'note_id': note_id}): - flash(_('Error updating note'), 'error') - return render_template('client_notes/edit.html', note=note, client_id=client_id) - + + if not safe_commit("edit_client_note", {"note_id": note_id}): + flash(_("Error updating note"), "error") + return render_template("client_notes/edit.html", note=note, client_id=client_id) + # Log note update log_event("client_note.updated", user_id=current_user.id, client_note_id=note.id) track_event(current_user.id, "client_note.updated", {"note_id": note.id}) - - flash(_('Note updated successfully'), 'success') - return redirect(url_for('clients.view_client', client_id=client_id)) - + + flash(_("Note updated successfully"), "success") + return redirect(url_for("clients.view_client", client_id=client_id)) + except ValueError as e: - flash(_('Error updating note: %(error)s', error=str(e)), 'error') + flash(_("Error updating note: %(error)s", error=str(e)), "error") except Exception as e: - flash(_('Error updating note: %(error)s', error=str(e)), 'error') - - return render_template('client_notes/edit.html', note=note, client_id=client_id) + flash(_("Error updating note: %(error)s", error=str(e)), "error") -@client_notes_bp.route('/clients//notes//delete', methods=['POST']) + return render_template("client_notes/edit.html", note=note, client_id=client_id) + + +@client_notes_bp.route("/clients//notes//delete", methods=["POST"]) @login_required def delete_note(client_id, note_id): """Delete a client note""" note = ClientNote.query.get_or_404(note_id) - + # Verify note belongs to this client if note.client_id != client_id: - flash(_('Note does not belong to this client'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + flash(_("Note does not belong to this client"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + # Check permissions if not note.can_delete(current_user): - flash(_('You do not have permission to delete this note'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + flash(_("You do not have permission to delete this note"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + try: note_id_for_log = note.id - + db.session.delete(note) - - if not safe_commit('delete_client_note', {'note_id': note_id}): - flash(_('Error deleting note'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + + if not safe_commit("delete_client_note", {"note_id": note_id}): + flash(_("Error deleting note"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + # Log note deletion log_event("client_note.deleted", user_id=current_user.id, client_note_id=note_id_for_log) track_event(current_user.id, "client_note.deleted", {"note_id": note_id_for_log}) - - flash(_('Note deleted successfully'), 'success') - + + flash(_("Note deleted successfully"), "success") + except Exception as e: - flash(_('Error deleting note: %(error)s', error=str(e)), 'error') - - return redirect(url_for('clients.view_client', client_id=client_id)) + flash(_("Error deleting note: %(error)s", error=str(e)), "error") -@client_notes_bp.route('/clients//notes//toggle-important', methods=['POST']) + return redirect(url_for("clients.view_client", client_id=client_id)) + + +@client_notes_bp.route("/clients//notes//toggle-important", methods=["POST"]) def toggle_important(client_id, note_id): """Toggle the important flag on a client note""" # Explicit auth check to avoid redirect behavior from login_required for JSON flows if not getattr(current_user, "is_authenticated", False): - return jsonify({'error': 'Authentication required'}), 401 + return jsonify({"error": "Authentication required"}), 401 note = ClientNote.query.get_or_404(note_id) - + # Verify note belongs to this client if note.client_id != client_id: - return jsonify({'error': 'Note does not belong to this client'}), 400 - + return jsonify({"error": "Note does not belong to this client"}), 400 + # Check permissions if not note.can_edit(current_user): - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + try: note.is_important = not note.is_important - - if not safe_commit('toggle_important_note', {'note_id': note_id}): - return jsonify({'error': 'Error updating note'}), 500 - + + if not safe_commit("toggle_important_note", {"note_id": note_id}): + return jsonify({"error": "Error updating note"}), 500 + # Log note update - log_event("client_note.importance_toggled", - user_id=current_user.id, - client_note_id=note.id, - is_important=note.is_important) - track_event(current_user.id, "client_note.importance_toggled", { - "note_id": note.id, - "is_important": note.is_important - }) - - return jsonify({ - 'success': True, - 'is_important': note.is_important - }) - + log_event( + "client_note.importance_toggled", + user_id=current_user.id, + client_note_id=note.id, + is_important=note.is_important, + ) + track_event( + current_user.id, "client_note.importance_toggled", {"note_id": note.id, "is_important": note.is_important} + ) + + return jsonify({"success": True, "is_important": note.is_important}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@client_notes_bp.route('/api/clients//notes') + +@client_notes_bp.route("/api/clients//notes") def list_notes(client_id): """API endpoint to get notes for a client""" # Explicit auth check to avoid redirect behavior from login_required for JSON flows if not getattr(current_user, "is_authenticated", False): - return jsonify({'error': 'Authentication required'}), 401 - order_by_important = request.args.get('order_by_important', 'false').lower() == 'true' - + return jsonify({"error": "Authentication required"}), 401 + order_by_important = request.args.get("order_by_important", "false").lower() == "true" + try: # Verify client exists client = Client.query.get_or_404(client_id) notes = ClientNote.get_client_notes(client_id, order_by_important) - - return jsonify({ - 'success': True, - 'notes': [note.to_dict() for note in notes] - }) - + + return jsonify({"success": True, "notes": [note.to_dict() for note in notes]}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 + -@client_notes_bp.route('/api/client-notes/') +@client_notes_bp.route("/api/client-notes/") def get_note(note_id): """API endpoint to get a single client note""" # Explicit auth check to avoid redirect behavior from login_required for JSON flows if not getattr(current_user, "is_authenticated", False): - return jsonify({'error': 'Authentication required'}), 401 + return jsonify({"error": "Authentication required"}), 401 try: note = ClientNote.query.get_or_404(note_id) - return jsonify({ - 'success': True, - 'note': note.to_dict() - }) - + return jsonify({"success": True, "note": note.to_dict()}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 + -@client_notes_bp.route('/api/client-notes/important') +@client_notes_bp.route("/api/client-notes/important") def get_important_notes(): """API endpoint to get all important client notes""" # Explicit auth check to avoid redirect behavior from login_required for JSON flows if not getattr(current_user, "is_authenticated", False): - return jsonify({'error': 'Authentication required'}), 401 - client_id = request.args.get('client_id', type=int) - + return jsonify({"error": "Authentication required"}), 401 + client_id = request.args.get("client_id", type=int) + try: notes = ClientNote.get_important_notes(client_id) - return jsonify({ - 'success': True, - 'notes': [note.to_dict() for note in notes] - }) - + return jsonify({"success": True, "notes": [note.to_dict() for note in notes]}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 + -@client_notes_bp.route('/api/client-notes/recent') +@client_notes_bp.route("/api/client-notes/recent") def get_recent_notes(): """API endpoint to get recent client notes""" # Explicit auth check to avoid redirect behavior from login_required for JSON flows if not getattr(current_user, "is_authenticated", False): - return jsonify({'error': 'Authentication required'}), 401 - limit = request.args.get('limit', 10, type=int) - + return jsonify({"error": "Authentication required"}), 401 + limit = request.args.get("limit", 10, type=int) + try: notes = ClientNote.get_recent_notes(limit) - return jsonify({ - 'success': True, - 'notes': [note.to_dict() for note in notes] - }) - + return jsonify({"success": True, "notes": [note.to_dict() for note in notes]}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@client_notes_bp.route('/api/client-notes/user/') + +@client_notes_bp.route("/api/client-notes/user/") def get_user_notes(user_id): """API endpoint to get notes by a specific user""" # Explicit auth check to avoid redirect behavior from login_required for JSON flows if not getattr(current_user, "is_authenticated", False): - return jsonify({'error': 'Authentication required'}), 401 - limit = request.args.get('limit', type=int) - + return jsonify({"error": "Authentication required"}), 401 + limit = request.args.get("limit", type=int) + # Only allow users to see their own notes unless they're admin if not current_user.is_admin and current_user.id != user_id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + try: notes = ClientNote.get_user_notes(user_id, limit) - return jsonify({ - 'success': True, - 'notes': [note.to_dict() for note in notes] - }) - - except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"success": True, "notes": [note.to_dict() for note in notes]}) + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/app/routes/client_portal.py b/app/routes/client_portal.py index 73638324..2090fa8f 100644 --- a/app/routes/client_portal.py +++ b/app/routes/client_portal.py @@ -3,6 +3,7 @@ Provides a simplified interface for clients to view their projects, invoices, and time entries. Uses separate authentication from regular users. """ + from flask import Blueprint, render_template, request, redirect, url_for, flash, abort, session from flask_babel import gettext as _ from app import db @@ -12,23 +13,23 @@ from sqlalchemy import func from functools import wraps -client_portal_bp = Blueprint('client_portal', __name__) +client_portal_bp = Blueprint("client_portal", __name__) def get_current_client(): """Get the currently logged-in client from session (either Client or User portal access)""" # Check for Client portal authentication - client_id = session.get('client_portal_id') + client_id = session.get("client_portal_id") if client_id: return Client.query.get(client_id) - + # Check for User portal authentication - user_id = session.get('_user_id') + user_id = session.get("_user_id") if user_id: user = User.query.get(user_id) if user and user.is_client_portal_user: return user.client # Return the Client object linked to the user - + return None @@ -41,34 +42,34 @@ def inject_get_current_client(): def check_client_portal_access(): """Helper function to check if client has portal access - returns 403 for users without access, redirects to login if not authenticated - + Returns: Client: The Client object if access is granted Response: A redirect response if authentication is needed None: If 403 is raised (abort is called) """ # Check for Client portal authentication - client_id = session.get('client_portal_id') + client_id = session.get("client_portal_id") if client_id: client = Client.query.get(client_id) if not client: - flash(_('Please log in to access the client portal.'), 'error') - return redirect(url_for('client_portal.login', next=request.url)) - + flash(_("Please log in to access the client portal."), "error") + return redirect(url_for("client_portal.login", next=request.url)) + if not client.has_portal_access: - flash(_('Client portal access is not enabled for your account.'), 'error') - session.pop('client_portal_id', None) # Clear invalid session - return redirect(url_for('client_portal.login')) - + flash(_("Client portal access is not enabled for your account."), "error") + session.pop("client_portal_id", None) # Clear invalid session + return redirect(url_for("client_portal.login")) + if not client.is_active: - flash(_('Your client account is inactive.'), 'error') - session.pop('client_portal_id', None) # Clear invalid session - return redirect(url_for('client_portal.login')) - + flash(_("Your client account is inactive."), "error") + session.pop("client_portal_id", None) # Clear invalid session + return redirect(url_for("client_portal.login")) + return client - + # Check for User portal authentication - user_id = session.get('_user_id') + user_id = session.get("_user_id") if user_id: try: # Convert to int if it's a string (session stores it as string) @@ -76,11 +77,12 @@ def check_client_portal_access(): user_id = int(user_id) # Query with options to ensure we get fresh data and load relationships from sqlalchemy.orm import joinedload + user = User.query.options(joinedload(User.client)).get(user_id) except (ValueError, TypeError): # Invalid user_id format - flash(_('Please log in to access the client portal.'), 'error') - return redirect(url_for('client_portal.login', next=request.url)) + flash(_("Please log in to access the client portal."), "error") + return redirect(url_for("client_portal.login", next=request.url)) except Exception: # If there's a session error, try to rollback and retry try: @@ -88,44 +90,45 @@ def check_client_portal_access(): user = User.query.options(joinedload(User.client)).get(user_id) except Exception: db.session.rollback() - flash(_('Please log in to access the client portal.'), 'error') - return redirect(url_for('client_portal.login', next=request.url)) - + flash(_("Please log in to access the client portal."), "error") + return redirect(url_for("client_portal.login", next=request.url)) + if not user: - flash(_('Please log in to access the client portal.'), 'error') - return redirect(url_for('client_portal.login', next=request.url)) - + flash(_("Please log in to access the client portal."), "error") + return redirect(url_for("client_portal.login", next=request.url)) + # Check portal access directly to ensure we have the latest values if not (user.client_portal_enabled and user.client_id is not None): # User is logged in but doesn't have portal access - return 403 abort(403) - + if not user.is_active: abort(403) - + # Ensure client relationship is loaded - query directly if not loaded if not user.client and user.client_id: # Query the client directly if relationship not loaded from app.models import Client + client = Client.query.get(user.client_id) if not client: abort(403) return client - + if not user.client: abort(403) - + return user.client - + # No authentication at all - redirect to login - flash(_('Please log in to access the client portal.'), 'error') - return redirect(url_for('client_portal.login', next=request.url)) + flash(_("Please log in to access the client portal."), "error") + return redirect(url_for("client_portal.login", next=request.url)) def get_portal_data(client): """Get portal data for a client, handling both Client and User authentication""" # Check if this is a User accessing via client portal - user_id = session.get('_user_id') + user_id = session.get("_user_id") if user_id: try: # Convert to int if it's a string @@ -139,106 +142,106 @@ def get_portal_data(client): except Exception: db.session.rollback() # Fall through to Client method - + # Otherwise use Client's get_portal_data method return client.get_portal_data() -@client_portal_bp.route('/client-portal/login', methods=['GET', 'POST']) +@client_portal_bp.route("/client-portal/login", methods=["GET", "POST"]) def login(): """Client portal login page""" - if request.method == 'GET': + if request.method == "GET": # If already logged in, redirect to dashboard if get_current_client(): - return redirect(url_for('client_portal.dashboard')) - return render_template('client_portal/login.html') - + return redirect(url_for("client_portal.dashboard")) + return render_template("client_portal/login.html") + # POST - handle login - username = request.form.get('username', '').strip() - password = request.form.get('password', '') - + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if not username or not password: - flash(_('Username and password are required.'), 'error') - return render_template('client_portal/login.html') - + flash(_("Username and password are required."), "error") + return render_template("client_portal/login.html") + # Authenticate client client = Client.authenticate_portal(username, password) - + if not client: - flash(_('Invalid username or password.'), 'error') - return render_template('client_portal/login.html') - + flash(_("Invalid username or password."), "error") + return render_template("client_portal/login.html") + # Log in the client - session['client_portal_id'] = client.id + session["client_portal_id"] = client.id session.permanent = True - - flash(_('Welcome, %(client_name)s!', client_name=client.name), 'success') - + + flash(_("Welcome, %(client_name)s!", client_name=client.name), "success") + # Redirect to intended page or dashboard - next_page = request.form.get('next') or request.args.get('next') - if not next_page or not next_page.startswith('/client-portal'): - next_page = url_for('client_portal.dashboard') - + next_page = request.form.get("next") or request.args.get("next") + if not next_page or not next_page.startswith("/client-portal"): + next_page = url_for("client_portal.dashboard") + return redirect(next_page) -@client_portal_bp.route('/client-portal/logout') +@client_portal_bp.route("/client-portal/logout") def logout(): """Client portal logout""" - session.pop('client_portal_id', None) - flash(_('You have been logged out.'), 'info') - return redirect(url_for('client_portal.login')) + session.pop("client_portal_id", None) + flash(_("You have been logged out."), "info") + return redirect(url_for("client_portal.login")) -@client_portal_bp.route('/client-portal/set-password', methods=['GET', 'POST']) +@client_portal_bp.route("/client-portal/set-password", methods=["GET", "POST"]) def set_password(): """Set or reset password using token from email""" - token = request.args.get('token') - + token = request.args.get("token") + if not token: - flash(_('Invalid or missing password setup token.'), 'error') - return redirect(url_for('client_portal.login')) - + flash(_("Invalid or missing password setup token."), "error") + return redirect(url_for("client_portal.login")) + # Find client by token client = Client.find_by_password_token(token) - + if not client: - flash(_('Invalid or expired password setup token. Please request a new one.'), 'error') - return redirect(url_for('client_portal.login')) - - if request.method == 'POST': - password = request.form.get('password', '').strip() - password_confirm = request.form.get('password_confirm', '').strip() - + flash(_("Invalid or expired password setup token. Please request a new one."), "error") + return redirect(url_for("client_portal.login")) + + if request.method == "POST": + password = request.form.get("password", "").strip() + password_confirm = request.form.get("password_confirm", "").strip() + # Validate password if not password: - flash(_('Password is required.'), 'error') - return render_template('client_portal/set_password.html', client=client, token=token) - + flash(_("Password is required."), "error") + return render_template("client_portal/set_password.html", client=client, token=token) + if len(password) < 8: - flash(_('Password must be at least 8 characters long.'), 'error') - return render_template('client_portal/set_password.html', client=client, token=token) - + flash(_("Password must be at least 8 characters long."), "error") + return render_template("client_portal/set_password.html", client=client, token=token) + if password != password_confirm: - flash(_('Passwords do not match.'), 'error') - return render_template('client_portal/set_password.html', client=client, token=token) - + flash(_("Passwords do not match."), "error") + return render_template("client_portal/set_password.html", client=client, token=token) + # Set password client.set_portal_password(password) client.clear_password_setup_token() - - if not safe_commit('client_set_password', {'client_id': client.id}): - flash(_('Could not set password due to a database error.'), 'error') - return render_template('client_portal/set_password.html', client=client, token=token) - - flash(_('Password set successfully! You can now log in to the portal.'), 'success') - return redirect(url_for('client_portal.login')) - - return render_template('client_portal/set_password.html', client=client, token=token) - - -@client_portal_bp.route('/client-portal') -@client_portal_bp.route('/client-portal/dashboard') + + if not safe_commit("client_set_password", {"client_id": client.id}): + flash(_("Could not set password due to a database error."), "error") + return render_template("client_portal/set_password.html", client=client, token=token) + + flash(_("Password set successfully! You can now log in to the portal."), "success") + return redirect(url_for("client_portal.login")) + + return render_template("client_portal/set_password.html", client=client, token=token) + + +@client_portal_bp.route("/client-portal") +@client_portal_bp.route("/client-portal/dashboard") def dashboard(): """Client portal dashboard showing overview of projects, invoices, and time entries""" result = check_client_portal_access() @@ -246,56 +249,46 @@ def dashboard(): return result client = result portal_data = get_portal_data(client) - + if not portal_data: - flash(_('Unable to load client portal data.'), 'error') - return redirect(url_for('client_portal.login')) - + flash(_("Unable to load client portal data."), "error") + return redirect(url_for("client_portal.login")) + # Calculate statistics - total_projects = len(portal_data['projects']) - total_invoices = len(portal_data['invoices']) - total_time_entries = len(portal_data['time_entries']) - + total_projects = len(portal_data["projects"]) + total_invoices = len(portal_data["invoices"]) + total_time_entries = len(portal_data["time_entries"]) + # Calculate total hours - total_hours = sum(entry.duration_hours for entry in portal_data['time_entries']) - + total_hours = sum(entry.duration_hours for entry in portal_data["time_entries"]) + # Calculate invoice totals - total_invoice_amount = sum(inv.total_amount for inv in portal_data['invoices']) - paid_invoice_amount = sum( - inv.total_amount for inv in portal_data['invoices'] - if inv.payment_status == 'fully_paid' - ) + total_invoice_amount = sum(inv.total_amount for inv in portal_data["invoices"]) + paid_invoice_amount = sum(inv.total_amount for inv in portal_data["invoices"] if inv.payment_status == "fully_paid") unpaid_invoice_amount = sum( - inv.outstanding_amount for inv in portal_data['invoices'] - if inv.payment_status != 'fully_paid' + inv.outstanding_amount for inv in portal_data["invoices"] if inv.payment_status != "fully_paid" ) - + # Get recent activity (last 30 days) thirty_days_ago = datetime.utcnow() - timedelta(days=30) - recent_time_entries = [ - entry for entry in portal_data['time_entries'] - if entry.start_time >= thirty_days_ago - ] - + recent_time_entries = [entry for entry in portal_data["time_entries"] if entry.start_time >= thirty_days_ago] + # Group time entries by project project_hours = {} - for entry in portal_data['time_entries']: + for entry in portal_data["time_entries"]: if not entry.project: continue project_id = entry.project.id if project_id not in project_hours: - project_hours[project_id] = { - 'project': entry.project, - 'hours': 0.0 - } - project_hours[project_id]['hours'] += entry.duration_hours - + project_hours[project_id] = {"project": entry.project, "hours": 0.0} + project_hours[project_id]["hours"] += entry.duration_hours + return render_template( - 'client_portal/dashboard.html', + "client_portal/dashboard.html", client=client, - projects=portal_data['projects'], - invoices=portal_data['invoices'], - time_entries=portal_data['time_entries'], + projects=portal_data["projects"], + invoices=portal_data["invoices"], + time_entries=portal_data["time_entries"], total_projects=total_projects, total_invoices=total_invoices, total_time_entries=total_time_entries, @@ -304,11 +297,11 @@ def dashboard(): paid_invoice_amount=paid_invoice_amount, unpaid_invoice_amount=unpaid_invoice_amount, recent_time_entries=recent_time_entries, - project_hours=list(project_hours.values()) + project_hours=list(project_hours.values()), ) -@client_portal_bp.route('/client-portal/projects') +@client_portal_bp.route("/client-portal/projects") def projects(): """List all projects for the client""" result = check_client_portal_access() @@ -316,34 +309,25 @@ def projects(): return result client = result portal_data = get_portal_data(client) - + if not portal_data: - flash(_('Unable to load client portal data.'), 'error') - return redirect(url_for('client_portal.dashboard')) - + flash(_("Unable to load client portal data."), "error") + return redirect(url_for("client_portal.dashboard")) + # Calculate hours per project project_stats = [] - for project in portal_data['projects']: - project_entries = [ - entry for entry in portal_data['time_entries'] - if entry.project_id == project.id - ] + for project in portal_data["projects"]: + project_entries = [entry for entry in portal_data["time_entries"] if entry.project_id == project.id] total_hours = sum(entry.duration_hours for entry in project_entries) - - project_stats.append({ - 'project': project, - 'total_hours': round(total_hours, 2), - 'entry_count': len(project_entries) - }) - - return render_template( - 'client_portal/projects.html', - client=client, - project_stats=project_stats - ) + + project_stats.append( + {"project": project, "total_hours": round(total_hours, 2), "entry_count": len(project_entries)} + ) + + return render_template("client_portal/projects.html", client=client, project_stats=project_stats) -@client_portal_bp.route('/client-portal/invoices') +@client_portal_bp.route("/client-portal/invoices") def invoices(): """List all invoices for the client""" result = check_client_portal_access() @@ -351,97 +335,78 @@ def invoices(): return result client = result portal_data = get_portal_data(client) - + if not portal_data: - flash(_('Unable to load client portal data.'), 'error') - return redirect(url_for('client_portal.dashboard')) - + flash(_("Unable to load client portal data."), "error") + return redirect(url_for("client_portal.dashboard")) + # Filter invoices by status if requested - status_filter = request.args.get('status', 'all') - filtered_invoices = portal_data['invoices'] - - if status_filter == 'paid': - filtered_invoices = [inv for inv in filtered_invoices if inv.payment_status == 'fully_paid'] - elif status_filter == 'unpaid': - filtered_invoices = [ - inv for inv in filtered_invoices - if inv.payment_status in ['unpaid', 'partially_paid'] - ] - elif status_filter == 'overdue': + status_filter = request.args.get("status", "all") + filtered_invoices = portal_data["invoices"] + + if status_filter == "paid": + filtered_invoices = [inv for inv in filtered_invoices if inv.payment_status == "fully_paid"] + elif status_filter == "unpaid": + filtered_invoices = [inv for inv in filtered_invoices if inv.payment_status in ["unpaid", "partially_paid"]] + elif status_filter == "overdue": filtered_invoices = [inv for inv in filtered_invoices if inv.is_overdue] - + return render_template( - 'client_portal/invoices.html', - client=client, - invoices=filtered_invoices, - status_filter=status_filter + "client_portal/invoices.html", client=client, invoices=filtered_invoices, status_filter=status_filter ) -@client_portal_bp.route('/client-portal/invoices/') +@client_portal_bp.route("/client-portal/invoices/") def view_invoice(invoice_id): """View a specific invoice""" result = check_client_portal_access() if not isinstance(result, Client): return result client = result - + # Verify invoice belongs to this client invoice = Invoice.query.get_or_404(invoice_id) if invoice.client_id != client.id: - flash(_('Invoice not found.'), 'error') + flash(_("Invoice not found."), "error") abort(404) - - return render_template( - 'client_portal/invoice_detail.html', - client=client, - invoice=invoice - ) + + return render_template("client_portal/invoice_detail.html", client=client, invoice=invoice) -@client_portal_bp.route('/client-portal/quotes') +@client_portal_bp.route("/client-portal/quotes") def quotes(): """List all quotes visible to the client""" result = check_client_portal_access() if not isinstance(result, Client): return result client = result - + # Get quotes visible to client - quotes_list = Quote.query.filter_by( - client_id=client.id, - visible_to_client=True - ).order_by(Quote.created_at.desc()).all() - - return render_template( - 'client_portal/quotes.html', - client=client, - quotes=quotes_list + quotes_list = ( + Quote.query.filter_by(client_id=client.id, visible_to_client=True).order_by(Quote.created_at.desc()).all() ) + return render_template("client_portal/quotes.html", client=client, quotes=quotes_list) + -@client_portal_bp.route('/client-portal/quotes/') +@client_portal_bp.route("/client-portal/quotes/") def view_quote(quote_id): """View a specific quote""" result = check_client_portal_access() if not isinstance(result, Client): return result client = result - + # Verify quote belongs to this client and is visible quote = Quote.query.get_or_404(quote_id) if quote.client_id != client.id or not quote.visible_to_client: - flash(_('Quote not found.'), 'error') + flash(_("Quote not found."), "error") abort(404) - - return render_template( - 'client_portal/quote_detail.html', - client=client, - quote=quote - ) + + return render_template("client_portal/quote_detail.html", client=client, quote=quote) -@client_portal_bp.route('/client-portal/time-entries') +@client_portal_bp.route("/client-portal/time-entries") def time_entries(): """List time entries for the client's projects""" result = check_client_portal_access() @@ -449,52 +414,42 @@ def time_entries(): return result client = result portal_data = get_portal_data(client) - + if not portal_data: - flash(_('Unable to load client portal data.'), 'error') - return redirect(url_for('client_portal.dashboard')) - + flash(_("Unable to load client portal data."), "error") + return redirect(url_for("client_portal.dashboard")) + # Filter by project if requested - project_id = request.args.get('project_id', type=int) - filtered_entries = portal_data['time_entries'] - + project_id = request.args.get("project_id", type=int) + filtered_entries = portal_data["time_entries"] + if project_id: - filtered_entries = [ - entry for entry in filtered_entries - if entry.project_id == project_id - ] - + filtered_entries = [entry for entry in filtered_entries if entry.project_id == project_id] + # Filter by date range if requested - date_from = request.args.get('date_from') - date_to = request.args.get('date_to') - + date_from = request.args.get("date_from") + date_to = request.args.get("date_to") + if date_from: try: - date_from_dt = datetime.strptime(date_from, '%Y-%m-%d') - filtered_entries = [ - entry for entry in filtered_entries - if entry.start_time.date() >= date_from_dt.date() - ] + date_from_dt = datetime.strptime(date_from, "%Y-%m-%d") + filtered_entries = [entry for entry in filtered_entries if entry.start_time.date() >= date_from_dt.date()] except ValueError: pass - + if date_to: try: - date_to_dt = datetime.strptime(date_to, '%Y-%m-%d') - filtered_entries = [ - entry for entry in filtered_entries - if entry.start_time.date() <= date_to_dt.date() - ] + date_to_dt = datetime.strptime(date_to, "%Y-%m-%d") + filtered_entries = [entry for entry in filtered_entries if entry.start_time.date() <= date_to_dt.date()] except ValueError: pass - + return render_template( - 'client_portal/time_entries.html', + "client_portal/time_entries.html", client=client, - projects=portal_data['projects'], + projects=portal_data["projects"], time_entries=filtered_entries, selected_project_id=project_id, date_from=date_from, - date_to=date_to + date_to=date_to, ) - diff --git a/app/routes/clients.py b/app/routes/clients.py index b827685f..a0e2f518 100644 --- a/app/routes/clients.py +++ b/app/routes/clients.py @@ -13,21 +13,22 @@ import csv import io -clients_bp = Blueprint('clients', __name__) +clients_bp = Blueprint("clients", __name__) -@clients_bp.route('/clients') + +@clients_bp.route("/clients") @login_required def list_clients(): """List all clients""" - status = request.args.get('status', 'active') - search = request.args.get('search', '').strip() - + status = request.args.get("status", "active") + search = request.args.get("search", "").strip() + query = Client.query - if status == 'active': - query = query.filter_by(status='active') - elif status == 'inactive': - query = query.filter_by(status='inactive') - + if status == "active": + query = query.filter_by(status="active") + elif status == "inactive": + query = query.filter_by(status="inactive") + if search: like = f"%{search}%" query = query.filter( @@ -35,114 +36,113 @@ def list_clients(): Client.name.ilike(like), Client.description.ilike(like), Client.contact_person.ilike(like), - Client.email.ilike(like) + Client.email.ilike(like), ) ) - + clients = query.order_by(Client.name).all() - - return render_template('clients/list.html', clients=clients, status=status, search=search) -@clients_bp.route('/clients/create', methods=['GET', 'POST']) + return render_template("clients/list.html", clients=clients, status=status, search=search) + + +@clients_bp.route("/clients/create", methods=["GET", "POST"]) @login_required def create_client(): """Create a new client""" # Detect AJAX/JSON request while preserving classic form behavior try: # Consider classic HTML forms regardless of Accept header - is_classic_form = request.mimetype in ( - 'application/x-www-form-urlencoded', - 'multipart/form-data' - ) + is_classic_form = request.mimetype in ("application/x-www-form-urlencoded", "multipart/form-data") except Exception: is_classic_form = False try: wants_json = ( - request.headers.get('X-Requested-With') == 'XMLHttpRequest' + request.headers.get("X-Requested-With") == "XMLHttpRequest" or request.is_json - or (not is_classic_form and ( - request.accept_mimetypes['application/json'] > request.accept_mimetypes['text/html'] - )) + or ( + not is_classic_form + and (request.accept_mimetypes["application/json"] > request.accept_mimetypes["text/html"]) + ) ) except Exception: wants_json = False # Check permissions - if not current_user.is_admin and not current_user.has_permission('create_clients'): + if not current_user.is_admin and not current_user.has_permission("create_clients"): if wants_json: - return jsonify({ - 'error': 'forbidden', - 'message': _('You do not have permission to create clients') - }), 403 - flash(_('You do not have permission to create clients'), 'error') - return redirect(url_for('clients.list_clients')) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - contact_person = request.form.get('contact_person', '').strip() - email = request.form.get('email', '').strip() - phone = request.form.get('phone', '').strip() - address = request.form.get('address', '').strip() - default_hourly_rate = request.form.get('default_hourly_rate', '').strip() - prepaid_hours_input = request.form.get('prepaid_hours_monthly', '').strip() - prepaid_reset_day_input = request.form.get('prepaid_reset_day', '').strip() + return jsonify({"error": "forbidden", "message": _("You do not have permission to create clients")}), 403 + flash(_("You do not have permission to create clients"), "error") + return redirect(url_for("clients.list_clients")) + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + contact_person = request.form.get("contact_person", "").strip() + email = request.form.get("email", "").strip() + phone = request.form.get("phone", "").strip() + address = request.form.get("address", "").strip() + default_hourly_rate = request.form.get("default_hourly_rate", "").strip() + prepaid_hours_input = request.form.get("prepaid_hours_monthly", "").strip() + prepaid_reset_day_input = request.form.get("prepaid_reset_day", "").strip() try: current_app.logger.info( "POST /clients/create user=%s name=%s email=%s", current_user.username, - name or '', - email or '' + name or "", + email or "", ) except Exception: pass - + # Validate required fields if not name: if wants_json: - return jsonify({'error': 'validation_error', 'messages': ['Client name is required']}), 400 - flash(_('Client name is required'), 'error') + return jsonify({"error": "validation_error", "messages": ["Client name is required"]}), 400 + flash(_("Client name is required"), "error") try: current_app.logger.warning("Validation failed: missing client name") except Exception: pass - return render_template('clients/create.html') - + return render_template("clients/create.html") + # Check if client name already exists if Client.query.filter_by(name=name).first(): if wants_json: - return jsonify({'error': 'validation_error', 'messages': ['A client with this name already exists']}), 400 - flash(_('A client with this name already exists'), 'error') + return ( + jsonify({"error": "validation_error", "messages": ["A client with this name already exists"]}), + 400, + ) + flash(_("A client with this name already exists"), "error") try: current_app.logger.warning("Validation failed: duplicate client name '%s'", name) except Exception: pass - return render_template('clients/create.html') - + return render_template("clients/create.html") + # Validate hourly rate try: default_hourly_rate = Decimal(default_hourly_rate) if default_hourly_rate else None except (InvalidOperation, ValueError): if wants_json: - return jsonify({'error': 'validation_error', 'messages': ['Invalid hourly rate format']}), 400 - flash(_('Invalid hourly rate format'), 'error') + return jsonify({"error": "validation_error", "messages": ["Invalid hourly rate format"]}), 400 + flash(_("Invalid hourly rate format"), "error") try: current_app.logger.warning("Validation failed: invalid hourly rate '%s'", default_hourly_rate) except Exception: pass - return render_template('clients/create.html') + return render_template("clients/create.html") try: prepaid_hours_monthly = Decimal(prepaid_hours_input) if prepaid_hours_input else None if prepaid_hours_monthly is not None and prepaid_hours_monthly < 0: raise InvalidOperation except (InvalidOperation, ValueError): - message = _('Prepaid hours must be a positive number.') + message = _("Prepaid hours must be a positive number.") if wants_json: - return jsonify({'error': 'validation_error', 'messages': [message]}), 400 - flash(message, 'error') - return render_template('clients/create.html') + return jsonify({"error": "validation_error", "messages": [message]}), 400 + flash(message, "error") + return render_template("clients/create.html") try: prepaid_reset_day = int(prepaid_reset_day_input) if prepaid_reset_day_input else 1 @@ -150,12 +150,12 @@ def create_client(): prepaid_reset_day = 1 if prepaid_reset_day < 1 or prepaid_reset_day > 28: - message = _('Prepaid reset day must be between 1 and 28.') + message = _("Prepaid reset day must be between 1 and 28.") if wants_json: - return jsonify({'error': 'validation_error', 'messages': [message]}), 400 - flash(message, 'error') - return render_template('clients/create.html') - + return jsonify({"error": "validation_error", "messages": [message]}), 400 + flash(message, "error") + return render_template("clients/create.html") + # Create client client = Client( name=name, @@ -166,48 +166,62 @@ def create_client(): address=address, default_hourly_rate=default_hourly_rate, prepaid_hours_monthly=prepaid_hours_monthly, - prepaid_reset_day=prepaid_reset_day + prepaid_reset_day=prepaid_reset_day, ) - + db.session.add(client) - if not safe_commit('create_client', {'name': name}): + if not safe_commit("create_client", {"name": name}): if wants_json: - return jsonify({'error': 'db_error', 'message': 'Could not create client due to a database error.'}), 500 - flash(_('Could not create client due to a database error. Please check server logs.'), 'error') - return render_template('clients/create.html') - + return ( + jsonify({"error": "db_error", "message": "Could not create client due to a database error."}), + 500, + ) + flash(_("Could not create client due to a database error. Please check server logs."), "error") + return render_template("clients/create.html") + # Log client creation app_module.log_event("client.created", user_id=current_user.id, client_id=client.id) app_module.track_event(current_user.id, "client.created", {"client_id": client.id}) - + if wants_json: - return jsonify({ - 'id': client.id, - 'name': client.name, - 'default_hourly_rate': float(client.default_hourly_rate) if client.default_hourly_rate is not None else None, - 'prepaid_hours_monthly': float(client.prepaid_hours_monthly) if client.prepaid_hours_monthly is not None else None, - 'prepaid_reset_day': client.prepaid_reset_day - }), 201 - - flash(f'Client "{name}" created successfully', 'success') - return redirect(url_for('clients.view_client', client_id=client.id)) - - return render_template('clients/create.html') - -@clients_bp.route('/clients/') + return ( + jsonify( + { + "id": client.id, + "name": client.name, + "default_hourly_rate": ( + float(client.default_hourly_rate) if client.default_hourly_rate is not None else None + ), + "prepaid_hours_monthly": ( + float(client.prepaid_hours_monthly) if client.prepaid_hours_monthly is not None else None + ), + "prepaid_reset_day": client.prepaid_reset_day, + } + ), + 201, + ) + + flash(f'Client "{name}" created successfully', "success") + return redirect(url_for("clients.view_client", client_id=client.id)) + + return render_template("clients/create.html") + + +@clients_bp.route("/clients/") @login_required def view_client(client_id): """View client details and projects""" client = Client.query.get_or_404(client_id) - + # Get projects for this client projects = Project.query.filter_by(client_id=client.id).order_by(Project.name).all() - + # Get contacts for this client (if CRM tables exist) contacts = [] primary_contact = None try: from app.models import Contact + contacts = Contact.get_active_contacts(client_id) primary_contact = Contact.get_primary_contact(client_id) except Exception as e: @@ -220,97 +234,102 @@ def view_client(client_id): if client.prepaid_plan_enabled: today = datetime.utcnow() month_start = client.prepaid_month_start(today) - consumed_hours = client.get_prepaid_consumed_hours(month_start).quantize(Decimal('0.01')) - remaining_hours = client.get_prepaid_remaining_hours(month_start).quantize(Decimal('0.01')) + consumed_hours = client.get_prepaid_consumed_hours(month_start).quantize(Decimal("0.01")) + remaining_hours = client.get_prepaid_remaining_hours(month_start).quantize(Decimal("0.01")) prepaid_overview = { - 'month_start': month_start, - 'month_label': month_start.strftime('%Y-%m-%d') if month_start else '', - 'plan_hours': float(client.prepaid_hours_decimal), - 'consumed_hours': float(consumed_hours), - 'remaining_hours': float(remaining_hours), + "month_start": month_start, + "month_label": month_start.strftime("%Y-%m-%d") if month_start else "", + "plan_hours": float(client.prepaid_hours_decimal), + "consumed_hours": float(consumed_hours), + "remaining_hours": float(remaining_hours), } - - return render_template('clients/view.html', - client=client, - projects=projects, - contacts=contacts, - primary_contact=primary_contact, - prepaid_overview=prepaid_overview) - -@clients_bp.route('/clients//edit', methods=['GET', 'POST']) + + return render_template( + "clients/view.html", + client=client, + projects=projects, + contacts=contacts, + primary_contact=primary_contact, + prepaid_overview=prepaid_overview, + ) + + +@clients_bp.route("/clients//edit", methods=["GET", "POST"]) @login_required def edit_client(client_id): """Edit client details""" client = Client.query.get_or_404(client_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_clients'): - flash(_('You do not have permission to edit clients'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - contact_person = request.form.get('contact_person', '').strip() - email = request.form.get('email', '').strip() - phone = request.form.get('phone', '').strip() - address = request.form.get('address', '').strip() - default_hourly_rate = request.form.get('default_hourly_rate', '').strip() - prepaid_hours_input = request.form.get('prepaid_hours_monthly', '').strip() - prepaid_reset_day_input = request.form.get('prepaid_reset_day', '').strip() - + if not current_user.is_admin and not current_user.has_permission("edit_clients"): + flash(_("You do not have permission to edit clients"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + contact_person = request.form.get("contact_person", "").strip() + email = request.form.get("email", "").strip() + phone = request.form.get("phone", "").strip() + address = request.form.get("address", "").strip() + default_hourly_rate = request.form.get("default_hourly_rate", "").strip() + prepaid_hours_input = request.form.get("prepaid_hours_monthly", "").strip() + prepaid_reset_day_input = request.form.get("prepaid_reset_day", "").strip() + # Validate required fields if not name: - flash(_('Client name is required'), 'error') - return render_template('clients/edit.html', client=client) - + flash(_("Client name is required"), "error") + return render_template("clients/edit.html", client=client) + # Check if client name already exists (excluding current client) existing = Client.query.filter_by(name=name).first() if existing and existing.id != client.id: - flash(_('A client with this name already exists'), 'error') - return render_template('clients/edit.html', client=client) - + flash(_("A client with this name already exists"), "error") + return render_template("clients/edit.html", client=client) + # Validate hourly rate try: default_hourly_rate = Decimal(default_hourly_rate) if default_hourly_rate else None except (InvalidOperation, ValueError): - flash(_('Invalid hourly rate format'), 'error') - return render_template('clients/edit.html', client=client) + flash(_("Invalid hourly rate format"), "error") + return render_template("clients/edit.html", client=client) try: prepaid_hours_monthly = Decimal(prepaid_hours_input) if prepaid_hours_input else None if prepaid_hours_monthly is not None and prepaid_hours_monthly < 0: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Prepaid hours must be a positive number.'), 'error') - return render_template('clients/edit.html', client=client) + flash(_("Prepaid hours must be a positive number."), "error") + return render_template("clients/edit.html", client=client) try: - prepaid_reset_day = int(prepaid_reset_day_input) if prepaid_reset_day_input else client.prepaid_reset_day or 1 + prepaid_reset_day = ( + int(prepaid_reset_day_input) if prepaid_reset_day_input else client.prepaid_reset_day or 1 + ) except ValueError: prepaid_reset_day = client.prepaid_reset_day or 1 if prepaid_reset_day < 1 or prepaid_reset_day > 28: - flash(_('Prepaid reset day must be between 1 and 28.'), 'error') - return render_template('clients/edit.html', client=client) - + flash(_("Prepaid reset day must be between 1 and 28."), "error") + return render_template("clients/edit.html", client=client) + # Handle portal settings - portal_enabled = request.form.get('portal_enabled') == 'on' - portal_username = request.form.get('portal_username', '').strip() - portal_password = request.form.get('portal_password', '').strip() - + portal_enabled = request.form.get("portal_enabled") == "on" + portal_username = request.form.get("portal_username", "").strip() + portal_password = request.form.get("portal_password", "").strip() + # Validate portal settings if portal_enabled: if not portal_username: - flash(_('Portal username is required when enabling portal access.'), 'error') - return render_template('clients/edit.html', client=client) - + flash(_("Portal username is required when enabling portal access."), "error") + return render_template("clients/edit.html", client=client) + # Check if portal username is already taken by another client existing_client = Client.query.filter_by(portal_username=portal_username).first() if existing_client and existing_client.id != client.id: - flash(_('This portal username is already in use by another client.'), 'error') - return render_template('clients/edit.html', client=client) - + flash(_("This portal username is already in use by another client."), "error") + return render_template("clients/edit.html", client=client) + # Update client client.name = name client.description = description @@ -322,7 +341,7 @@ def edit_client(client_id): client.prepaid_hours_monthly = prepaid_hours_monthly client.prepaid_reset_day = prepaid_reset_day client.portal_enabled = portal_enabled - + # Update portal credentials if portal_enabled: client.portal_username = portal_username @@ -332,295 +351,322 @@ def edit_client(client_id): # Disable portal - clear credentials client.portal_username = None client.portal_password_hash = None - + client.updated_at = datetime.utcnow() - - if not safe_commit('edit_client', {'client_id': client.id}): - flash(_('Could not update client due to a database error. Please check server logs.'), 'error') - return render_template('clients/edit.html', client=client) - + + if not safe_commit("edit_client", {"client_id": client.id}): + flash(_("Could not update client due to a database error. Please check server logs."), "error") + return render_template("clients/edit.html", client=client) + # Log client update app_module.log_event("client.updated", user_id=current_user.id, client_id=client.id) app_module.track_event(current_user.id, "client.updated", {"client_id": client.id}) - - flash(f'Client "{name}" updated successfully', 'success') - return redirect(url_for('clients.view_client', client_id=client.id)) - - return render_template('clients/edit.html', client=client) + + flash(f'Client "{name}" updated successfully', "success") + return redirect(url_for("clients.view_client", client_id=client.id)) + + return render_template("clients/edit.html", client=client) -@clients_bp.route('/clients//send-portal-password-email', methods=['POST']) +@clients_bp.route("/clients//send-portal-password-email", methods=["POST"]) @login_required def send_portal_password_email(client_id): """Send password setup email to client""" client = Client.query.get_or_404(client_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_clients'): - flash(_('You do not have permission to send portal emails'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + if not current_user.is_admin and not current_user.has_permission("edit_clients"): + flash(_("You do not have permission to send portal emails"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + # Check if portal is enabled and username is set if not client.portal_enabled: - flash(_('Client portal is not enabled for this client.'), 'error') - return redirect(url_for('clients.edit_client', client_id=client_id)) - + flash(_("Client portal is not enabled for this client."), "error") + return redirect(url_for("clients.edit_client", client_id=client_id)) + if not client.portal_username: - flash(_('Portal username is not set for this client.'), 'error') - return redirect(url_for('clients.edit_client', client_id=client_id)) - + flash(_("Portal username is not set for this client."), "error") + return redirect(url_for("clients.edit_client", client_id=client_id)) + if not client.email: - flash(_('Client email address is not set. Cannot send password setup email.'), 'error') - return redirect(url_for('clients.edit_client', client_id=client_id)) - + flash(_("Client email address is not set. Cannot send password setup email."), "error") + return redirect(url_for("clients.edit_client", client_id=client_id)) + # Generate password setup token token = client.generate_password_setup_token(expires_hours=24) - - if not safe_commit('client_generate_password_token', {'client_id': client.id}): - flash(_('Could not generate password setup token due to a database error.'), 'error') - return redirect(url_for('clients.edit_client', client_id=client_id)) - + + if not safe_commit("client_generate_password_token", {"client_id": client.id}): + flash(_("Could not generate password setup token due to a database error."), "error") + return redirect(url_for("clients.edit_client", client_id=client_id)) + # Send email try: # Ensure we're using latest database email settings from app.utils.email import reload_mail_config from app.models import Settings + settings = Settings.get_settings() if settings.mail_enabled: reload_mail_config(current_app._get_current_object()) - + success = send_client_portal_password_setup_email(client, token) if success: - flash(_('Password setup email sent successfully to %(email)s', email=client.email), 'success') + flash(_("Password setup email sent successfully to %(email)s", email=client.email), "success") else: # Check email configuration to provide better error message db_config = settings.get_mail_config() if db_config: - mail_server = db_config.get('MAIL_SERVER') + mail_server = db_config.get("MAIL_SERVER") else: - mail_server = current_app.config.get('MAIL_SERVER') - - if not mail_server or mail_server == 'localhost': - flash(_('Email server is not configured. Please configure email settings in Admin → Email Configuration or set MAIL_SERVER environment variable.'), 'error') + mail_server = current_app.config.get("MAIL_SERVER") + + if not mail_server or mail_server == "localhost": + flash( + _( + "Email server is not configured. Please configure email settings in Admin → Email Configuration or set MAIL_SERVER environment variable." + ), + "error", + ) else: - flash(_('Failed to send password setup email. Please check email configuration and server logs for details.'), 'error') + flash( + _( + "Failed to send password setup email. Please check email configuration and server logs for details." + ), + "error", + ) except Exception as e: current_app.logger.error(f"Error sending password setup email: {e}") - flash(_('An error occurred while sending the email: %(error)s', error=str(e)), 'error') - - return redirect(url_for('clients.edit_client', client_id=client_id)) + flash(_("An error occurred while sending the email: %(error)s", error=str(e)), "error") + + return redirect(url_for("clients.edit_client", client_id=client_id)) -@clients_bp.route('/clients//archive', methods=['POST']) + +@clients_bp.route("/clients//archive", methods=["POST"]) @login_required def archive_client(client_id): """Archive a client""" client = Client.query.get_or_404(client_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_clients'): - flash(_('You do not have permission to archive clients'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - - if client.status == 'inactive': - flash(_('Client is already inactive'), 'info') + if not current_user.is_admin and not current_user.has_permission("edit_clients"): + flash(_("You do not have permission to archive clients"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + + if client.status == "inactive": + flash(_("Client is already inactive"), "info") else: client.archive() app_module.log_event("client.archived", user_id=current_user.id, client_id=client.id) app_module.track_event(current_user.id, "client.archived", {"client_id": client.id}) - flash(f'Client "{client.name}" archived successfully', 'success') - - return redirect(url_for('clients.list_clients')) + flash(f'Client "{client.name}" archived successfully', "success") -@clients_bp.route('/clients//activate', methods=['POST']) + return redirect(url_for("clients.list_clients")) + + +@clients_bp.route("/clients//activate", methods=["POST"]) @login_required def activate_client(client_id): """Activate a client""" client = Client.query.get_or_404(client_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_clients'): - flash(_('You do not have permission to activate clients'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - - if client.status == 'active': - flash(_('Client is already active'), 'info') + if not current_user.is_admin and not current_user.has_permission("edit_clients"): + flash(_("You do not have permission to activate clients"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + + if client.status == "active": + flash(_("Client is already active"), "info") else: client.activate() - flash(f'Client "{client.name}" activated successfully', 'success') - - return redirect(url_for('clients.list_clients')) + flash(f'Client "{client.name}" activated successfully', "success") -@clients_bp.route('/clients//delete', methods=['POST']) + return redirect(url_for("clients.list_clients")) + + +@clients_bp.route("/clients//delete", methods=["POST"]) @login_required def delete_client(client_id): """Delete a client (only if no projects exist)""" client = Client.query.get_or_404(client_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('delete_clients'): - flash(_('You do not have permission to delete clients'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + if not current_user.is_admin and not current_user.has_permission("delete_clients"): + flash(_("You do not have permission to delete clients"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + # Check if client has projects if client.projects.count() > 0: - flash(_('Cannot delete client with existing projects'), 'error') - return redirect(url_for('clients.view_client', client_id=client_id)) - + flash(_("Cannot delete client with existing projects"), "error") + return redirect(url_for("clients.view_client", client_id=client_id)) + client_name = client.name client_id_for_log = client.id db.session.delete(client) - if not safe_commit('delete_client', {'client_id': client.id}): - flash(_('Could not delete client due to a database error. Please check server logs.'), 'error') - return redirect(url_for('clients.view_client', client_id=client.id)) - + if not safe_commit("delete_client", {"client_id": client.id}): + flash(_("Could not delete client due to a database error. Please check server logs."), "error") + return redirect(url_for("clients.view_client", client_id=client.id)) + # Log client deletion app_module.log_event("client.deleted", user_id=current_user.id, client_id=client_id_for_log) app_module.track_event(current_user.id, "client.deleted", {"client_id": client_id_for_log}) - - flash(f'Client "{client_name}" deleted successfully', 'success') - return redirect(url_for('clients.list_clients')) -@clients_bp.route('/clients/bulk-delete', methods=['POST']) + flash(f'Client "{client_name}" deleted successfully', "success") + return redirect(url_for("clients.list_clients")) + + +@clients_bp.route("/clients/bulk-delete", methods=["POST"]) @login_required def bulk_delete_clients(): """Delete multiple clients at once""" # Check permissions - if not current_user.is_admin and not current_user.has_permission('delete_clients'): - flash(_('You do not have permission to delete clients'), 'error') - return redirect(url_for('clients.list_clients')) - - client_ids = request.form.getlist('client_ids[]') - + if not current_user.is_admin and not current_user.has_permission("delete_clients"): + flash(_("You do not have permission to delete clients"), "error") + return redirect(url_for("clients.list_clients")) + + client_ids = request.form.getlist("client_ids[]") + if not client_ids: - flash(_('No clients selected for deletion'), 'warning') - return redirect(url_for('clients.list_clients')) - + flash(_("No clients selected for deletion"), "warning") + return redirect(url_for("clients.list_clients")) + deleted_count = 0 skipped_count = 0 errors = [] - + for client_id_str in client_ids: try: client_id = int(client_id_str) client = Client.query.get(client_id) - + if not client: continue - + # Check for projects if client.projects.count() > 0: skipped_count += 1 errors.append(f"'{client.name}': Has projects") continue - + # Delete the client client_id_for_log = client.id client_name = client.name - + db.session.delete(client) deleted_count += 1 - + # Log the deletion app_module.log_event("client.deleted", user_id=current_user.id, client_id=client_id_for_log) app_module.track_event(current_user.id, "client.deleted", {"client_id": client_id_for_log}) - + except Exception as e: skipped_count += 1 errors.append(f"ID {client_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: - if not safe_commit('bulk_delete_clients', {'count': deleted_count}): - flash(_('Could not delete clients due to a database error. Please check server logs.'), 'error') - return redirect(url_for('clients.list_clients')) - + if not safe_commit("bulk_delete_clients", {"count": deleted_count}): + flash(_("Could not delete clients due to a database error. Please check server logs."), "error") + return redirect(url_for("clients.list_clients")) + # Show appropriate messages if deleted_count > 0: - flash(f'Successfully deleted {deleted_count} client{"s" if deleted_count != 1 else ""}', 'success') - + flash(f'Successfully deleted {deleted_count} client{"s" if deleted_count != 1 else ""}', "success") + if skipped_count > 0: - flash(f'Skipped {skipped_count} client{"s" if skipped_count != 1 else ""}: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning') - + flash( + f'Skipped {skipped_count} client{"s" if skipped_count != 1 else ""}: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', + "warning", + ) + if deleted_count == 0 and skipped_count == 0: - flash(_('No clients were deleted'), 'info') - - return redirect(url_for('clients.list_clients')) + flash(_("No clients were deleted"), "info") + + return redirect(url_for("clients.list_clients")) + -@clients_bp.route('/clients/bulk-status-change', methods=['POST']) +@clients_bp.route("/clients/bulk-status-change", methods=["POST"]) @login_required def bulk_status_change(): """Change status for multiple clients at once""" # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_clients'): - flash(_('You do not have permission to change client status'), 'error') - return redirect(url_for('clients.list_clients')) - - client_ids = request.form.getlist('client_ids[]') - new_status = request.form.get('new_status', '').strip() - + if not current_user.is_admin and not current_user.has_permission("edit_clients"): + flash(_("You do not have permission to change client status"), "error") + return redirect(url_for("clients.list_clients")) + + client_ids = request.form.getlist("client_ids[]") + new_status = request.form.get("new_status", "").strip() + if not client_ids: - flash(_('No clients selected'), 'warning') - return redirect(url_for('clients.list_clients')) - - if new_status not in ['active', 'inactive']: - flash(_('Invalid status'), 'error') - return redirect(url_for('clients.list_clients')) - + flash(_("No clients selected"), "warning") + return redirect(url_for("clients.list_clients")) + + if new_status not in ["active", "inactive"]: + flash(_("Invalid status"), "error") + return redirect(url_for("clients.list_clients")) + updated_count = 0 errors = [] - + for client_id_str in client_ids: try: client_id = int(client_id_str) client = Client.query.get(client_id) - + if not client: continue - + # Update status client.status = new_status client.updated_at = datetime.utcnow() updated_count += 1 - + # Log the status change app_module.log_event(f"client.status_changed_{new_status}", user_id=current_user.id, client_id=client.id) - app_module.track_event(current_user.id, "client.status_changed", {"client_id": client.id, "new_status": new_status}) - + app_module.track_event( + current_user.id, "client.status_changed", {"client_id": client.id, "new_status": new_status} + ) + except Exception as e: errors.append(f"ID {client_id_str}: {str(e)}") - + # Commit all changes if updated_count > 0: - if not safe_commit('bulk_status_change_clients', {'count': updated_count, 'status': new_status}): - flash(_('Could not update client status due to a database error. Please check server logs.'), 'error') - return redirect(url_for('clients.list_clients')) - + if not safe_commit("bulk_status_change_clients", {"count": updated_count, "status": new_status}): + flash(_("Could not update client status due to a database error. Please check server logs."), "error") + return redirect(url_for("clients.list_clients")) + # Show appropriate messages - status_labels = {'active': 'active', 'inactive': 'inactive'} + status_labels = {"active": "active", "inactive": "inactive"} if updated_count > 0: - flash(f'Successfully marked {updated_count} client{"s" if updated_count != 1 else ""} as {status_labels.get(new_status, new_status)}', 'success') - + flash( + f'Successfully marked {updated_count} client{"s" if updated_count != 1 else ""} as {status_labels.get(new_status, new_status)}', + "success", + ) + if errors: - flash(f'Some clients could not be updated: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning') - + flash( + f'Some clients could not be updated: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', "warning" + ) + if updated_count == 0: - flash(_('No clients were updated'), 'info') - - return redirect(url_for('clients.list_clients')) + flash(_("No clients were updated"), "info") + + return redirect(url_for("clients.list_clients")) + -@clients_bp.route('/clients/export') +@clients_bp.route("/clients/export") @login_required def export_clients(): """Export clients to CSV""" - status = request.args.get('status', 'active') - search = request.args.get('search', '').strip() - + status = request.args.get("status", "active") + search = request.args.get("search", "").strip() + query = Client.query - if status == 'active': - query = query.filter_by(status='active') - elif status == 'inactive': - query = query.filter_by(status='inactive') - + if status == "active": + query = query.filter_by(status="active") + elif status == "inactive": + query = query.filter_by(status="inactive") + if search: like = f"%{search}%" query = query.filter( @@ -628,65 +674,86 @@ def export_clients(): Client.name.ilike(like), Client.description.ilike(like), Client.contact_person.ilike(like), - Client.email.ilike(like) + Client.email.ilike(like), ) ) - + clients = query.order_by(Client.name).all() - + # Create CSV in memory output = io.StringIO() writer = csv.writer(output) - + # Write header - writer.writerow([ - 'ID', - 'Name', - 'Description', - 'Contact Person', - 'Email', - 'Phone', - 'Address', - 'Default Hourly Rate', - 'Status', - 'Active Projects', - 'Total Projects', - 'Created At', - 'Updated At' - ]) - + writer.writerow( + [ + "ID", + "Name", + "Description", + "Contact Person", + "Email", + "Phone", + "Address", + "Default Hourly Rate", + "Status", + "Active Projects", + "Total Projects", + "Created At", + "Updated At", + ] + ) + # Write client data for client in clients: - writer.writerow([ - client.id, - client.name, - client.description or '', - client.contact_person or '', - client.email or '', - client.phone or '', - client.address or '', - client.default_hourly_rate or '', - client.status, - client.active_projects, - client.total_projects, - (convert_app_datetime_to_user(client.created_at, user=current_user).strftime('%Y-%m-%d %H:%M:%S') if client.created_at else ''), - (convert_app_datetime_to_user(client.updated_at, user=current_user).strftime('%Y-%m-%d %H:%M:%S') if client.updated_at else '') - ]) - + writer.writerow( + [ + client.id, + client.name, + client.description or "", + client.contact_person or "", + client.email or "", + client.phone or "", + client.address or "", + client.default_hourly_rate or "", + client.status, + client.active_projects, + client.total_projects, + ( + convert_app_datetime_to_user(client.created_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S") + if client.created_at + else "" + ), + ( + convert_app_datetime_to_user(client.updated_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S") + if client.updated_at + else "" + ), + ] + ) + # Create response output.seek(0) return Response( output.getvalue(), - mimetype='text/csv', + mimetype="text/csv", headers={ - 'Content-Disposition': f'attachment; filename=clients_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' - } + "Content-Disposition": f'attachment; filename=clients_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + }, ) -@clients_bp.route('/api/clients') +@clients_bp.route("/api/clients") @login_required def api_clients(): """API endpoint to get clients for dropdowns""" clients = Client.get_active_clients() - return {'clients': [{'id': c.id, 'name': c.name, 'default_rate': float(c.default_hourly_rate) if c.default_hourly_rate else None} for c in clients]} + return { + "clients": [ + { + "id": c.id, + "name": c.name, + "default_rate": float(c.default_hourly_rate) if c.default_hourly_rate else None, + } + for c in clients + ] + } diff --git a/app/routes/comments.py b/app/routes/comments.py index 169b718d..9f716310 100644 --- a/app/routes/comments.py +++ b/app/routes/comments.py @@ -5,194 +5,194 @@ from app.models import Comment, Project, Task, Quote from app.utils.db import safe_commit -comments_bp = Blueprint('comments', __name__) +comments_bp = Blueprint("comments", __name__) -@comments_bp.route('/comments/create', methods=['POST']) + +@comments_bp.route("/comments/create", methods=["POST"]) @login_required def create_comment(): """Create a new comment for a project or task""" try: - content = request.form.get('content', '').strip() - project_id = request.form.get('project_id', type=int) - task_id = request.form.get('task_id', type=int) - quote_id = request.form.get('quote_id', type=int) - parent_id = request.form.get('parent_id', type=int) - is_internal = request.form.get('is_internal', 'true').lower() == 'true' - + content = request.form.get("content", "").strip() + project_id = request.form.get("project_id", type=int) + task_id = request.form.get("task_id", type=int) + quote_id = request.form.get("quote_id", type=int) + parent_id = request.form.get("parent_id", type=int) + is_internal = request.form.get("is_internal", "true").lower() == "true" + # Validation if not content: - flash(_('Comment content cannot be empty'), 'error') - return redirect(request.referrer or url_for('main.dashboard')) - + flash(_("Comment content cannot be empty"), "error") + return redirect(request.referrer or url_for("main.dashboard")) + if not project_id and not task_id and not quote_id: - flash(_('Comment must be associated with a project, task, or quote'), 'error') - return redirect(request.referrer or url_for('main.dashboard')) - + flash(_("Comment must be associated with a project, task, or quote"), "error") + return redirect(request.referrer or url_for("main.dashboard")) + # Ensure only one target is set targets = [x for x in [project_id, task_id, quote_id] if x is not None] if len(targets) > 1: - flash(_('Comment cannot be associated with multiple targets'), 'error') - return redirect(request.referrer or url_for('main.dashboard')) - + flash(_("Comment cannot be associated with multiple targets"), "error") + return redirect(request.referrer or url_for("main.dashboard")) + # Verify target exists if project_id: target = Project.query.get_or_404(project_id) - target_type = 'project' + target_type = "project" elif task_id: target = Task.query.get_or_404(task_id) - target_type = 'task' + target_type = "task" project_id = target.project_id # For redirects else: target = Quote.query.get_or_404(quote_id) - target_type = 'quote' - + target_type = "quote" + # If this is a reply, verify parent comment exists if parent_id: parent_comment = Comment.query.get_or_404(parent_id) # Verify parent is for the same target - if (project_id and parent_comment.project_id != project_id) or \ - (task_id and parent_comment.task_id != task_id) or \ - (quote_id and parent_comment.quote_id != quote_id): - flash(_('Invalid parent comment'), 'error') - return redirect(request.referrer or url_for('main.dashboard')) - + if ( + (project_id and parent_comment.project_id != project_id) + or (task_id and parent_comment.task_id != task_id) + or (quote_id and parent_comment.quote_id != quote_id) + ): + flash(_("Invalid parent comment"), "error") + return redirect(request.referrer or url_for("main.dashboard")) + # Create the comment comment = Comment( content=content, user_id=current_user.id, - project_id=project_id if target_type == 'project' else None, - task_id=task_id if target_type == 'task' else None, - quote_id=quote_id if target_type == 'quote' else None, + project_id=project_id if target_type == "project" else None, + task_id=task_id if target_type == "task" else None, + quote_id=quote_id if target_type == "quote" else None, parent_id=parent_id, - is_internal=is_internal + is_internal=is_internal, ) - + db.session.add(comment) if safe_commit(): # Log comment creation - log_event("comment.created", - user_id=current_user.id, - comment_id=comment.id, - target_type=target_type) - track_event(current_user.id, "comment.created", { - "comment_id": comment.id, - "target_type": target_type - }) - flash(_('Comment added successfully'), 'success') + log_event("comment.created", user_id=current_user.id, comment_id=comment.id, target_type=target_type) + track_event(current_user.id, "comment.created", {"comment_id": comment.id, "target_type": target_type}) + flash(_("Comment added successfully"), "success") else: - flash(_('Error adding comment'), 'error') - + flash(_("Error adding comment"), "error") + except Exception as e: - flash(_('Error adding comment: %(error)s', error=str(e)), 'error') - + flash(_("Error adding comment: %(error)s", error=str(e)), "error") + # Redirect back to the source page if project_id: - return redirect(url_for('projects.view_project', project_id=project_id)) + return redirect(url_for("projects.view_project", project_id=project_id)) elif task_id: - return redirect(url_for('tasks.view_task', task_id=task_id)) + return redirect(url_for("tasks.view_task", task_id=task_id)) elif quote_id: - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) else: - return redirect(request.referrer or url_for('main.dashboard')) + return redirect(request.referrer or url_for("main.dashboard")) + -@comments_bp.route('/comments//edit', methods=['GET', 'POST']) +@comments_bp.route("/comments//edit", methods=["GET", "POST"]) @login_required def edit_comment(comment_id): """Edit an existing comment""" comment = Comment.query.get_or_404(comment_id) - + # Check permissions if not comment.can_edit(current_user): - flash(_('You do not have permission to edit this comment'), 'error') - return redirect(request.referrer or url_for('main.dashboard')) - - if request.method == 'POST': + flash(_("You do not have permission to edit this comment"), "error") + return redirect(request.referrer or url_for("main.dashboard")) + + if request.method == "POST": try: - content = request.form.get('content', '').strip() - + content = request.form.get("content", "").strip() + if not content: - flash(_('Comment content cannot be empty'), 'error') - return render_template('comments/edit.html', comment=comment) - + flash(_("Comment content cannot be empty"), "error") + return render_template("comments/edit.html", comment=comment) + comment.edit_content(content, current_user) - + # Log comment update log_event("comment.updated", user_id=current_user.id, comment_id=comment.id) track_event(current_user.id, "comment.updated", {"comment_id": comment.id}) - - flash(_('Comment updated successfully'), 'success') - + + flash(_("Comment updated successfully"), "success") + # Redirect back to the source page if comment.project_id: - return redirect(url_for('projects.view_project', project_id=comment.project_id)) + return redirect(url_for("projects.view_project", project_id=comment.project_id)) elif comment.task_id: - return redirect(url_for('tasks.view_task', task_id=comment.task_id)) + return redirect(url_for("tasks.view_task", task_id=comment.task_id)) elif comment.quote_id: - return redirect(url_for('quotes.view_quote', quote_id=comment.quote_id)) + return redirect(url_for("quotes.view_quote", quote_id=comment.quote_id)) else: - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + except Exception as e: - flash(_('Error updating comment: %(error)s', error=str(e)), 'error') - - return render_template('comments/edit.html', comment=comment) + flash(_("Error updating comment: %(error)s", error=str(e)), "error") + + return render_template("comments/edit.html", comment=comment) -@comments_bp.route('/comments//delete', methods=['POST']) + +@comments_bp.route("/comments//delete", methods=["POST"]) @login_required def delete_comment(comment_id): """Delete a comment""" comment = Comment.query.get_or_404(comment_id) - + # Check permissions if not comment.can_delete(current_user): - flash(_('You do not have permission to delete this comment'), 'error') - return redirect(request.referrer or url_for('main.dashboard')) - + flash(_("You do not have permission to delete this comment"), "error") + return redirect(request.referrer or url_for("main.dashboard")) + try: project_id = comment.project_id task_id = comment.task_id quote_id = comment.quote_id comment_id_for_log = comment.id - + comment.delete_comment(current_user) - + # Log comment deletion log_event("comment.deleted", user_id=current_user.id, comment_id=comment_id_for_log) track_event(current_user.id, "comment.deleted", {"comment_id": comment_id_for_log}) - - flash(_('Comment deleted successfully'), 'success') - + + flash(_("Comment deleted successfully"), "success") + # Redirect back to the source page if project_id: - return redirect(url_for('projects.view_project', project_id=project_id)) + return redirect(url_for("projects.view_project", project_id=project_id)) elif task_id: - return redirect(url_for('tasks.view_task', task_id=task_id)) + return redirect(url_for("tasks.view_task", task_id=task_id)) elif quote_id: - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) else: - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + except Exception as e: - flash(_('Error deleting comment: %(error)s', error=str(e)), 'error') - return redirect(request.referrer or url_for('main.dashboard')) + flash(_("Error deleting comment: %(error)s", error=str(e)), "error") + return redirect(request.referrer or url_for("main.dashboard")) -@comments_bp.route('/api/comments') + +@comments_bp.route("/api/comments") @login_required def list_comments(): """API endpoint to get comments for a project, task, or quote""" - project_id = request.args.get('project_id', type=int) - task_id = request.args.get('task_id', type=int) - quote_id = request.args.get('quote_id', type=int) - include_replies = request.args.get('include_replies', 'true').lower() == 'true' - include_internal = request.args.get('include_internal', 'true').lower() == 'true' - + project_id = request.args.get("project_id", type=int) + task_id = request.args.get("task_id", type=int) + quote_id = request.args.get("quote_id", type=int) + include_replies = request.args.get("include_replies", "true").lower() == "true" + include_internal = request.args.get("include_internal", "true").lower() == "true" + targets = [x for x in [project_id, task_id, quote_id] if x is not None] if len(targets) == 0: - return jsonify({'error': 'project_id, task_id, or quote_id is required'}), 400 - + return jsonify({"error": "project_id, task_id, or quote_id is required"}), 400 + if len(targets) > 1: - return jsonify({'error': 'Cannot specify multiple targets'}), 400 - + return jsonify({"error": "Cannot specify multiple targets"}), 400 + try: if project_id: # Verify project exists @@ -206,61 +206,52 @@ def list_comments(): # Verify quote exists quote = Quote.query.get_or_404(quote_id) comments = Comment.get_quote_comments(quote_id, include_replies, include_internal) - - return jsonify({ - 'success': True, - 'comments': [comment.to_dict() for comment in comments] - }) - + + return jsonify({"success": True, "comments": [comment.to_dict() for comment in comments]}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 + -@comments_bp.route('/api/comments/') +@comments_bp.route("/api/comments/") @login_required def get_comment(comment_id): """API endpoint to get a single comment""" try: comment = Comment.query.get_or_404(comment_id) - return jsonify({ - 'success': True, - 'comment': comment.to_dict() - }) - + return jsonify({"success": True, "comment": comment.to_dict()}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@comments_bp.route('/api/comments/recent') + +@comments_bp.route("/api/comments/recent") @login_required def get_recent_comments(): """API endpoint to get recent comments""" - limit = request.args.get('limit', 10, type=int) - + limit = request.args.get("limit", 10, type=int) + try: comments = Comment.get_recent_comments(limit) - return jsonify({ - 'success': True, - 'comments': [comment.to_dict() for comment in comments] - }) - + return jsonify({"success": True, "comments": [comment.to_dict() for comment in comments]}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 + -@comments_bp.route('/api/comments/user/') +@comments_bp.route("/api/comments/user/") @login_required def get_user_comments(user_id): """API endpoint to get comments by a specific user""" - limit = request.args.get('limit', type=int) - + limit = request.args.get("limit", type=int) + # Only allow users to see their own comments unless they're admin if not current_user.is_admin and current_user.id != user_id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + try: comments = Comment.get_user_comments(user_id, limit) - return jsonify({ - 'success': True, - 'comments': [comment.to_dict() for comment in comments] - }) - + return jsonify({"success": True, "comments": [comment.to_dict() for comment in comments]}) + except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 diff --git a/app/routes/contacts.py b/app/routes/contacts.py index 6a9fd136..fa314435 100644 --- a/app/routes/contacts.py +++ b/app/routes/contacts.py @@ -1,4 +1,5 @@ """Routes for contact management""" + from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user @@ -8,178 +9,186 @@ from app.utils.timezone import parse_local_datetime from datetime import datetime -contacts_bp = Blueprint('contacts', __name__) +contacts_bp = Blueprint("contacts", __name__) + -@contacts_bp.route('/clients//contacts') +@contacts_bp.route("/clients//contacts") @login_required def list_contacts(client_id): """List all contacts for a client""" client = Client.query.get_or_404(client_id) contacts = Contact.get_active_contacts(client_id) - return render_template('contacts/list.html', client=client, contacts=contacts) + return render_template("contacts/list.html", client=client, contacts=contacts) -@contacts_bp.route('/clients//contacts/create', methods=['GET', 'POST']) + +@contacts_bp.route("/clients//contacts/create", methods=["GET", "POST"]) @login_required def create_contact(client_id): """Create a new contact for a client""" client = Client.query.get_or_404(client_id) - - if request.method == 'POST': + + if request.method == "POST": try: contact = Contact( client_id=client_id, - first_name=request.form.get('first_name', '').strip(), - last_name=request.form.get('last_name', '').strip(), + first_name=request.form.get("first_name", "").strip(), + last_name=request.form.get("last_name", "").strip(), created_by=current_user.id, - email=request.form.get('email', '').strip() or None, - phone=request.form.get('phone', '').strip() or None, - mobile=request.form.get('mobile', '').strip() or None, - title=request.form.get('title', '').strip() or None, - department=request.form.get('department', '').strip() or None, - role=request.form.get('role', 'contact').strip() or 'contact', - is_primary=request.form.get('is_primary') == 'on', - address=request.form.get('address', '').strip() or None, - notes=request.form.get('notes', '').strip() or None, - tags=request.form.get('tags', '').strip() or None + email=request.form.get("email", "").strip() or None, + phone=request.form.get("phone", "").strip() or None, + mobile=request.form.get("mobile", "").strip() or None, + title=request.form.get("title", "").strip() or None, + department=request.form.get("department", "").strip() or None, + role=request.form.get("role", "contact").strip() or "contact", + is_primary=request.form.get("is_primary") == "on", + address=request.form.get("address", "").strip() or None, + notes=request.form.get("notes", "").strip() or None, + tags=request.form.get("tags", "").strip() or None, ) - + db.session.add(contact) - + # If this is set as primary, unset others if contact.is_primary: Contact.query.filter( - Contact.client_id == client_id, - Contact.id != contact.id, - Contact.is_primary == True - ).update({'is_primary': False}) - + Contact.client_id == client_id, Contact.id != contact.id, Contact.is_primary == True + ).update({"is_primary": False}) + if safe_commit(): - flash(_('Contact created successfully'), 'success') - return redirect(url_for('contacts.list_contacts', client_id=client_id)) + flash(_("Contact created successfully"), "success") + return redirect(url_for("contacts.list_contacts", client_id=client_id)) except Exception as e: db.session.rollback() - flash(_('Error creating contact: %(error)s', error=str(e)), 'error') - - return render_template('contacts/form.html', client=client, contact=None) + flash(_("Error creating contact: %(error)s", error=str(e)), "error") -@contacts_bp.route('/contacts/') + return render_template("contacts/form.html", client=client, contact=None) + + +@contacts_bp.route("/contacts/") @login_required def view_contact(contact_id): """View a contact""" contact = Contact.query.get_or_404(contact_id) communications = ContactCommunication.get_recent_communications(contact_id, limit=20) - return render_template('contacts/view.html', contact=contact, communications=communications) + return render_template("contacts/view.html", contact=contact, communications=communications) -@contacts_bp.route('/contacts//edit', methods=['GET', 'POST']) + +@contacts_bp.route("/contacts//edit", methods=["GET", "POST"]) @login_required def edit_contact(contact_id): """Edit a contact""" contact = Contact.query.get_or_404(contact_id) - - if request.method == 'POST': + + if request.method == "POST": try: - contact.first_name = request.form.get('first_name', '').strip() - contact.last_name = request.form.get('last_name', '').strip() - contact.email = request.form.get('email', '').strip() or None - contact.phone = request.form.get('phone', '').strip() or None - contact.mobile = request.form.get('mobile', '').strip() or None - contact.title = request.form.get('title', '').strip() or None - contact.department = request.form.get('department', '').strip() or None - contact.role = request.form.get('role', 'contact').strip() or 'contact' - contact.is_primary = request.form.get('is_primary') == 'on' - contact.address = request.form.get('address', '').strip() or None - contact.notes = request.form.get('notes', '').strip() or None - contact.tags = request.form.get('tags', '').strip() or None + contact.first_name = request.form.get("first_name", "").strip() + contact.last_name = request.form.get("last_name", "").strip() + contact.email = request.form.get("email", "").strip() or None + contact.phone = request.form.get("phone", "").strip() or None + contact.mobile = request.form.get("mobile", "").strip() or None + contact.title = request.form.get("title", "").strip() or None + contact.department = request.form.get("department", "").strip() or None + contact.role = request.form.get("role", "contact").strip() or "contact" + contact.is_primary = request.form.get("is_primary") == "on" + contact.address = request.form.get("address", "").strip() or None + contact.notes = request.form.get("notes", "").strip() or None + contact.tags = request.form.get("tags", "").strip() or None contact.updated_at = datetime.utcnow() - + # If this is set as primary, unset others if contact.is_primary: Contact.query.filter( - Contact.client_id == contact.client_id, - Contact.id != contact.id, - Contact.is_primary == True - ).update({'is_primary': False}) - + Contact.client_id == contact.client_id, Contact.id != contact.id, Contact.is_primary == True + ).update({"is_primary": False}) + if safe_commit(): - flash(_('Contact updated successfully'), 'success') - return redirect(url_for('contacts.view_contact', contact_id=contact_id)) + flash(_("Contact updated successfully"), "success") + return redirect(url_for("contacts.view_contact", contact_id=contact_id)) except Exception as e: db.session.rollback() - flash(_('Error updating contact: %(error)s', error=str(e)), 'error') - - return render_template('contacts/form.html', client=contact.client, contact=contact) + flash(_("Error updating contact: %(error)s", error=str(e)), "error") + + return render_template("contacts/form.html", client=contact.client, contact=contact) -@contacts_bp.route('/contacts//delete', methods=['POST']) + +@contacts_bp.route("/contacts//delete", methods=["POST"]) @login_required def delete_contact(contact_id): """Delete a contact (soft delete by setting is_active=False)""" contact = Contact.query.get_or_404(contact_id) - + try: contact.is_active = False contact.updated_at = datetime.utcnow() - + if safe_commit(): - flash(_('Contact deleted successfully'), 'success') + flash(_("Contact deleted successfully"), "success") except Exception as e: db.session.rollback() - flash(_('Error deleting contact: %(error)s', error=str(e)), 'error') - - return redirect(url_for('contacts.list_contacts', client_id=contact.client_id)) + flash(_("Error deleting contact: %(error)s", error=str(e)), "error") + + return redirect(url_for("contacts.list_contacts", client_id=contact.client_id)) -@contacts_bp.route('/contacts//set-primary', methods=['POST']) + +@contacts_bp.route("/contacts//set-primary", methods=["POST"]) @login_required def set_primary_contact(contact_id): """Set a contact as primary""" contact = Contact.query.get_or_404(contact_id) - + try: contact.set_as_primary() if safe_commit(): - flash(_('Contact set as primary'), 'success') + flash(_("Contact set as primary"), "success") except Exception as e: db.session.rollback() - flash(_('Error setting primary contact: %(error)s', error=str(e)), 'error') - - return redirect(url_for('contacts.list_contacts', client_id=contact.client_id)) + flash(_("Error setting primary contact: %(error)s", error=str(e)), "error") + + return redirect(url_for("contacts.list_contacts", client_id=contact.client_id)) -@contacts_bp.route('/contacts//communications/create', methods=['GET', 'POST']) + +@contacts_bp.route("/contacts//communications/create", methods=["GET", "POST"]) @login_required def create_communication(contact_id): """Create a communication record for a contact""" contact = Contact.query.get_or_404(contact_id) - - if request.method == 'POST': + + if request.method == "POST": try: - comm_date_str = request.form.get('communication_date', '') + comm_date_str = request.form.get("communication_date", "") comm_date = parse_local_datetime(comm_date_str) if comm_date_str else datetime.utcnow() - - follow_up_str = request.form.get('follow_up_date', '') + + follow_up_str = request.form.get("follow_up_date", "") follow_up_date = parse_local_datetime(follow_up_str) if follow_up_str else None - + communication = ContactCommunication( contact_id=contact_id, - type=request.form.get('type', 'note').strip(), + type=request.form.get("type", "note").strip(), created_by=current_user.id, - subject=request.form.get('subject', '').strip() or None, - content=request.form.get('content', '').strip() or None, - direction=request.form.get('direction', 'outbound').strip(), - status=request.form.get('status', 'completed').strip() or None, + subject=request.form.get("subject", "").strip() or None, + content=request.form.get("content", "").strip() or None, + direction=request.form.get("direction", "outbound").strip(), + status=request.form.get("status", "completed").strip() or None, communication_date=comm_date, follow_up_date=follow_up_date, - related_project_id=int(request.form.get('related_project_id')) if request.form.get('related_project_id') else None, - related_quote_id=int(request.form.get('related_quote_id')) if request.form.get('related_quote_id') else None, - related_deal_id=int(request.form.get('related_deal_id')) if request.form.get('related_deal_id') else None + related_project_id=( + int(request.form.get("related_project_id")) if request.form.get("related_project_id") else None + ), + related_quote_id=( + int(request.form.get("related_quote_id")) if request.form.get("related_quote_id") else None + ), + related_deal_id=( + int(request.form.get("related_deal_id")) if request.form.get("related_deal_id") else None + ), ) - + db.session.add(communication) - + if safe_commit(): - flash(_('Communication recorded successfully'), 'success') - return redirect(url_for('contacts.view_contact', contact_id=contact_id)) + flash(_("Communication recorded successfully"), "success") + return redirect(url_for("contacts.view_contact", contact_id=contact_id)) except Exception as e: db.session.rollback() - flash(_('Error recording communication: %(error)s', error=str(e)), 'error') - - return render_template('contacts/communication_form.html', contact=contact, communication=None) + flash(_("Error recording communication: %(error)s", error=str(e)), "error") + return render_template("contacts/communication_form.html", contact=contact, communication=None) diff --git a/app/routes/custom_reports.py b/app/routes/custom_reports.py index 97aa566b..09fff3bd 100644 --- a/app/routes/custom_reports.py +++ b/app/routes/custom_reports.py @@ -11,50 +11,43 @@ import json from datetime import datetime, timedelta -custom_reports_bp = Blueprint('custom_reports', __name__) +custom_reports_bp = Blueprint("custom_reports", __name__) -@custom_reports_bp.route('/reports/builder') +@custom_reports_bp.route("/reports/builder") @login_required def report_builder(): """Custom report builder page.""" saved_views = SavedReportView.query.filter_by(owner_id=current_user.id).all() - + # Get available data sources data_sources = [ - {'id': 'time_entries', 'name': 'Time Entries', 'icon': 'clock'}, - {'id': 'projects', 'name': 'Projects', 'icon': 'folder'}, - {'id': 'tasks', 'name': 'Tasks', 'icon': 'tasks'}, - {'id': 'invoices', 'name': 'Invoices', 'icon': 'file-invoice'}, - {'id': 'expenses', 'name': 'Expenses', 'icon': 'receipt'}, + {"id": "time_entries", "name": "Time Entries", "icon": "clock"}, + {"id": "projects", "name": "Projects", "icon": "folder"}, + {"id": "tasks", "name": "Tasks", "icon": "tasks"}, + {"id": "invoices", "name": "Invoices", "icon": "file-invoice"}, + {"id": "expenses", "name": "Expenses", "icon": "receipt"}, ] - - return render_template( - 'reports/builder.html', - saved_views=saved_views, - data_sources=data_sources - ) + + return render_template("reports/builder.html", saved_views=saved_views, data_sources=data_sources) -@custom_reports_bp.route('/reports/builder/save', methods=['POST']) +@custom_reports_bp.route("/reports/builder/save", methods=["POST"]) @login_required def save_report_view(): """Save a custom report view.""" try: data = request.json - name = data.get('name') - config = data.get('config', {}) - scope = data.get('scope', 'private') - + name = data.get("name") + config = data.get("config", {}) + scope = data.get("scope", "private") + if not name: - return jsonify({'success': False, 'message': 'Report name is required'}), 400 - + return jsonify({"success": False, "message": "Report name is required"}), 400 + # Check if name already exists - existing = SavedReportView.query.filter_by( - name=name, - owner_id=current_user.id - ).first() - + existing = SavedReportView.query.filter_by(name=name, owner_id=current_user.id).first() + if existing: # Update existing existing.config_json = json.dumps(config) @@ -63,172 +56,156 @@ def save_report_view(): else: # Create new saved_view = SavedReportView( - name=name, - owner_id=current_user.id, - scope=scope, - config_json=json.dumps(config) + name=name, owner_id=current_user.id, scope=scope, config_json=json.dumps(config) ) db.session.add(saved_view) - - if safe_commit('save_report_view', {'user_id': current_user.id}): - return jsonify({'success': True, 'message': 'Report saved successfully'}) + + if safe_commit("save_report_view", {"user_id": current_user.id}): + return jsonify({"success": True, "message": "Report saved successfully"}) else: - return jsonify({'success': False, 'message': 'Failed to save report'}), 500 - + return jsonify({"success": False, "message": "Failed to save report"}), 500 + except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 + return jsonify({"success": False, "message": str(e)}), 500 -@custom_reports_bp.route('/reports/builder/') +@custom_reports_bp.route("/reports/builder/") @login_required def view_custom_report(view_id): """View a custom report.""" saved_view = SavedReportView.query.get_or_404(view_id) - + # Check access - if saved_view.owner_id != current_user.id and saved_view.scope == 'private': - flash(_('You do not have permission to view this report.'), 'error') - return redirect(url_for('custom_reports.report_builder')) - + if saved_view.owner_id != current_user.id and saved_view.scope == "private": + flash(_("You do not have permission to view this report."), "error") + return redirect(url_for("custom_reports.report_builder")) + # Parse config try: config = json.loads(saved_view.config_json) except: config = {} - + # Generate report data based on config report_data = generate_report_data(config, current_user.id) - - return render_template( - 'reports/custom_view.html', - saved_view=saved_view, - config=config, - report_data=report_data - ) + return render_template("reports/custom_view.html", saved_view=saved_view, config=config, report_data=report_data) -@custom_reports_bp.route('/reports/builder/preview', methods=['POST']) + +@custom_reports_bp.route("/reports/builder/preview", methods=["POST"]) @login_required def preview_report(): """Preview report data based on configuration.""" try: data = request.json - config = data.get('config', {}) - + config = data.get("config", {}) + # Generate report data report_data = generate_report_data(config, current_user.id) - - return jsonify({ - 'success': True, - 'data': report_data - }) + + return jsonify({"success": True, "data": report_data}) except Exception as e: - return jsonify({ - 'success': False, - 'message': str(e) - }), 500 + return jsonify({"success": False, "message": str(e)}), 500 -@custom_reports_bp.route('/reports/builder//data', methods=['GET']) +@custom_reports_bp.route("/reports/builder//data", methods=["GET"]) @login_required def get_report_data(view_id): """Get report data as JSON.""" saved_view = SavedReportView.query.get_or_404(view_id) - + # Check access - if saved_view.owner_id != current_user.id and saved_view.scope == 'private': - return jsonify({'error': 'Access denied'}), 403 - + if saved_view.owner_id != current_user.id and saved_view.scope == "private": + return jsonify({"error": "Access denied"}), 403 + # Parse config try: config = json.loads(saved_view.config_json) except: config = {} - + # Generate report data report_data = generate_report_data(config, current_user.id) - + return jsonify(report_data) def generate_report_data(config, user_id=None): """Generate report data based on configuration.""" - data_source = config.get('data_source', 'time_entries') - filters = config.get('filters', {}) - columns = config.get('columns', []) - grouping = config.get('grouping', {}) - + data_source = config.get("data_source", "time_entries") + filters = config.get("filters", {}) + columns = config.get("columns", []) + grouping = config.get("grouping", {}) + # Parse date filters - start_date = filters.get('start_date') - end_date = filters.get('end_date') - + start_date = filters.get("start_date") + end_date = filters.get("end_date") + if start_date: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') + start_dt = datetime.strptime(start_date, "%Y-%m-%d") else: start_dt = datetime.utcnow() - timedelta(days=30) - + if end_date: - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) else: end_dt = datetime.utcnow() - + # Generate data based on source - if data_source == 'time_entries': + if data_source == "time_entries": query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt ) - + # Filter by user if not admin or if user_id is specified if user_id: user = User.query.get(user_id) if not user or not user.is_admin: query = query.filter(TimeEntry.user_id == user_id) - - if filters.get('project_id'): - query = query.filter(TimeEntry.project_id == filters['project_id']) - if filters.get('user_id'): - query = query.filter(TimeEntry.user_id == filters['user_id']) - + + if filters.get("project_id"): + query = query.filter(TimeEntry.project_id == filters["project_id"]) + if filters.get("user_id"): + query = query.filter(TimeEntry.user_id == filters["user_id"]) + entries = query.all() - + return { - 'data': [{ - 'id': e.id, - 'date': e.start_time.strftime('%Y-%m-%d') if e.start_time else '', - 'project': e.project.name if e.project else '', - 'user': e.user.username if e.user else '', - 'duration': e.duration_hours, - 'description': e.description or '' - } for e in entries], - 'summary': { - 'total_entries': len(entries), - 'total_hours': sum(e.duration_hours or 0 for e in entries) - } + "data": [ + { + "id": e.id, + "date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "", + "project": e.project.name if e.project else "", + "user": e.user.username if e.user else "", + "duration": e.duration_hours, + "description": e.description or "", + } + for e in entries + ], + "summary": {"total_entries": len(entries), "total_hours": sum(e.duration_hours or 0 for e in entries)}, } - - elif data_source == 'projects': + + elif data_source == "projects": query = Project.query - - if filters.get('status'): - query = query.filter(Project.status == filters['status']) - + + if filters.get("status"): + query = query.filter(Project.status == filters["status"]) + projects = query.all() - + return { - 'data': [{ - 'id': p.id, - 'name': p.name, - 'client': p.client.name if p.client else '', - 'status': p.status, - 'total_hours': sum(e.duration_hours or 0 for e in p.time_entries if e.end_time) - } for p in projects], - 'summary': { - 'total_projects': len(projects) - } + "data": [ + { + "id": p.id, + "name": p.name, + "client": p.client.name if p.client else "", + "status": p.status, + "total_hours": sum(e.duration_hours or 0 for e in p.time_entries if e.end_time), + } + for p in projects + ], + "summary": {"total_projects": len(projects)}, } - - # Add more data sources as needed - return {'data': [], 'summary': {}} + # Add more data sources as needed + return {"data": [], "summary": {}} diff --git a/app/routes/deals.py b/app/routes/deals.py index cb36f466..89d9c8b4 100644 --- a/app/routes/deals.py +++ b/app/routes/deals.py @@ -1,4 +1,5 @@ """Routes for deal/sales pipeline management""" + from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user @@ -9,315 +10,321 @@ from datetime import datetime, date from decimal import Decimal, InvalidOperation -deals_bp = Blueprint('deals', __name__) +deals_bp = Blueprint("deals", __name__) # Pipeline stages -PIPELINE_STAGES = [ - 'prospecting', - 'qualification', - 'proposal', - 'negotiation', - 'closed_won', - 'closed_lost' -] - -@deals_bp.route('/deals') +PIPELINE_STAGES = ["prospecting", "qualification", "proposal", "negotiation", "closed_won", "closed_lost"] + + +@deals_bp.route("/deals") @login_required def list_deals(): """List all deals with pipeline view""" - status = request.args.get('status', 'open') - stage = request.args.get('stage', '') - owner_id = request.args.get('owner', '') - + status = request.args.get("status", "open") + stage = request.args.get("stage", "") + owner_id = request.args.get("owner", "") + query = Deal.query - - if status == 'open': - query = query.filter_by(status='open') - elif status == 'won': - query = query.filter_by(status='won') - elif status == 'lost': - query = query.filter_by(status='lost') - + + if status == "open": + query = query.filter_by(status="open") + elif status == "won": + query = query.filter_by(status="won") + elif status == "lost": + query = query.filter_by(status="lost") + if stage: query = query.filter_by(stage=stage) - + if owner_id: try: query = query.filter_by(owner_id=int(owner_id)) except (ValueError, TypeError): pass - + deals = query.order_by(Deal.expected_close_date, Deal.created_at.desc()).all() - + # Group deals by stage for pipeline view deals_by_stage = {} for stage_name in PIPELINE_STAGES: deals_by_stage[stage_name] = [d for d in deals if d.stage == stage_name] - - return render_template('deals/list.html', - deals=deals, - deals_by_stage=deals_by_stage, - pipeline_stages=PIPELINE_STAGES, - status=status, - stage=stage, - owner_id=owner_id) - -@deals_bp.route('/deals/pipeline') + + return render_template( + "deals/list.html", + deals=deals, + deals_by_stage=deals_by_stage, + pipeline_stages=PIPELINE_STAGES, + status=status, + stage=stage, + owner_id=owner_id, + ) + + +@deals_bp.route("/deals/pipeline") @login_required def pipeline_view(): """Visual pipeline view of deals""" - owner_id = request.args.get('owner', '') - - query = Deal.query.filter_by(status='open') - + owner_id = request.args.get("owner", "") + + query = Deal.query.filter_by(status="open") + if owner_id: try: query = query.filter_by(owner_id=int(owner_id)) except (ValueError, TypeError): pass - + deals = query.all() - + # Group deals by stage deals_by_stage = {} for stage_name in PIPELINE_STAGES: deals_by_stage[stage_name] = [d for d in deals if d.stage == stage_name] - - return render_template('deals/pipeline.html', - deals_by_stage=deals_by_stage, - pipeline_stages=PIPELINE_STAGES, - owner_id=owner_id) -@deals_bp.route('/deals/create', methods=['GET', 'POST']) + return render_template( + "deals/pipeline.html", deals_by_stage=deals_by_stage, pipeline_stages=PIPELINE_STAGES, owner_id=owner_id + ) + + +@deals_bp.route("/deals/create", methods=["GET", "POST"]) @login_required def create_deal(): """Create a new deal""" - if request.method == 'POST': + if request.method == "POST": try: # Parse value - value_str = request.form.get('value', '').strip() + value_str = request.form.get("value", "").strip() value = None if value_str: try: value = Decimal(value_str) except (InvalidOperation, ValueError): - flash(_('Invalid deal value'), 'error') - return redirect(url_for('deals.create_deal')) - + flash(_("Invalid deal value"), "error") + return redirect(url_for("deals.create_deal")) + # Parse expected close date - close_date_str = request.form.get('expected_close_date', '').strip() + close_date_str = request.form.get("expected_close_date", "").strip() expected_close_date = None if close_date_str: try: - expected_close_date = datetime.strptime(close_date_str, '%Y-%m-%d').date() + expected_close_date = datetime.strptime(close_date_str, "%Y-%m-%d").date() except ValueError: pass - + deal = Deal( - name=request.form.get('name', '').strip(), + name=request.form.get("name", "").strip(), created_by=current_user.id, - client_id=int(request.form.get('client_id')) if request.form.get('client_id') else None, - contact_id=int(request.form.get('contact_id')) if request.form.get('contact_id') else None, - lead_id=int(request.form.get('lead_id')) if request.form.get('lead_id') else None, - description=request.form.get('description', '').strip() or None, - stage=request.form.get('stage', 'prospecting').strip(), + client_id=int(request.form.get("client_id")) if request.form.get("client_id") else None, + contact_id=int(request.form.get("contact_id")) if request.form.get("contact_id") else None, + lead_id=int(request.form.get("lead_id")) if request.form.get("lead_id") else None, + description=request.form.get("description", "").strip() or None, + stage=request.form.get("stage", "prospecting").strip(), value=value, - currency_code=request.form.get('currency_code', 'EUR').strip(), - probability=int(request.form.get('probability', 50)), + currency_code=request.form.get("currency_code", "EUR").strip(), + probability=int(request.form.get("probability", 50)), expected_close_date=expected_close_date, - related_quote_id=int(request.form.get('related_quote_id')) if request.form.get('related_quote_id') else None, - related_project_id=int(request.form.get('related_project_id')) if request.form.get('related_project_id') else None, - notes=request.form.get('notes', '').strip() or None, - owner_id=int(request.form.get('owner_id')) if request.form.get('owner_id') else current_user.id + related_quote_id=( + int(request.form.get("related_quote_id")) if request.form.get("related_quote_id") else None + ), + related_project_id=( + int(request.form.get("related_project_id")) if request.form.get("related_project_id") else None + ), + notes=request.form.get("notes", "").strip() or None, + owner_id=int(request.form.get("owner_id")) if request.form.get("owner_id") else current_user.id, ) - + db.session.add(deal) - + if safe_commit(): - flash(_('Deal created successfully'), 'success') - return redirect(url_for('deals.view_deal', deal_id=deal.id)) + flash(_("Deal created successfully"), "success") + return redirect(url_for("deals.view_deal", deal_id=deal.id)) except Exception as e: db.session.rollback() - flash(_('Error creating deal: %(error)s', error=str(e)), 'error') - + flash(_("Error creating deal: %(error)s", error=str(e)), "error") + # Get data for form - clients = Client.query.filter_by(status='active').order_by(Client.name).all() - quotes = Quote.query.filter_by(status='sent').order_by(Quote.created_at.desc()).all() - leads = Lead.query.filter(~Lead.status.in_(['converted', 'lost'])).order_by(Lead.created_at.desc()).all() - - return render_template('deals/form.html', - deal=None, - clients=clients, - quotes=quotes, - leads=leads, - pipeline_stages=PIPELINE_STAGES) - -@deals_bp.route('/deals/') + clients = Client.query.filter_by(status="active").order_by(Client.name).all() + quotes = Quote.query.filter_by(status="sent").order_by(Quote.created_at.desc()).all() + leads = Lead.query.filter(~Lead.status.in_(["converted", "lost"])).order_by(Lead.created_at.desc()).all() + + return render_template( + "deals/form.html", deal=None, clients=clients, quotes=quotes, leads=leads, pipeline_stages=PIPELINE_STAGES + ) + + +@deals_bp.route("/deals/") @login_required def view_deal(deal_id): """View a deal""" deal = Deal.query.get_or_404(deal_id) - activities = DealActivity.query.filter_by(deal_id=deal_id).order_by(DealActivity.activity_date.desc()).limit(50).all() - return render_template('deals/view.html', deal=deal, activities=activities) + activities = ( + DealActivity.query.filter_by(deal_id=deal_id).order_by(DealActivity.activity_date.desc()).limit(50).all() + ) + return render_template("deals/view.html", deal=deal, activities=activities) -@deals_bp.route('/deals//edit', methods=['GET', 'POST']) + +@deals_bp.route("/deals//edit", methods=["GET", "POST"]) @login_required def edit_deal(deal_id): """Edit a deal""" deal = Deal.query.get_or_404(deal_id) - - if request.method == 'POST': + + if request.method == "POST": try: # Parse value - value_str = request.form.get('value', '').strip() + value_str = request.form.get("value", "").strip() value = None if value_str: try: value = Decimal(value_str) except (InvalidOperation, ValueError): - flash(_('Invalid deal value'), 'error') - return redirect(url_for('deals.edit_deal', deal_id=deal_id)) - + flash(_("Invalid deal value"), "error") + return redirect(url_for("deals.edit_deal", deal_id=deal_id)) + # Parse expected close date - close_date_str = request.form.get('expected_close_date', '').strip() + close_date_str = request.form.get("expected_close_date", "").strip() expected_close_date = None if close_date_str: try: - expected_close_date = datetime.strptime(close_date_str, '%Y-%m-%d').date() + expected_close_date = datetime.strptime(close_date_str, "%Y-%m-%d").date() except ValueError: pass - - deal.name = request.form.get('name', '').strip() - deal.client_id = int(request.form.get('client_id')) if request.form.get('client_id') else None - deal.contact_id = int(request.form.get('contact_id')) if request.form.get('contact_id') else None - deal.description = request.form.get('description', '').strip() or None - deal.stage = request.form.get('stage', 'prospecting').strip() + + deal.name = request.form.get("name", "").strip() + deal.client_id = int(request.form.get("client_id")) if request.form.get("client_id") else None + deal.contact_id = int(request.form.get("contact_id")) if request.form.get("contact_id") else None + deal.description = request.form.get("description", "").strip() or None + deal.stage = request.form.get("stage", "prospecting").strip() deal.value = value - deal.currency_code = request.form.get('currency_code', 'EUR').strip() - deal.probability = int(request.form.get('probability', 50)) + deal.currency_code = request.form.get("currency_code", "EUR").strip() + deal.probability = int(request.form.get("probability", 50)) deal.expected_close_date = expected_close_date - deal.related_quote_id = int(request.form.get('related_quote_id')) if request.form.get('related_quote_id') else None - deal.related_project_id = int(request.form.get('related_project_id')) if request.form.get('related_project_id') else None - deal.notes = request.form.get('notes', '').strip() or None - deal.owner_id = int(request.form.get('owner_id')) if request.form.get('owner_id') else current_user.id + deal.related_quote_id = ( + int(request.form.get("related_quote_id")) if request.form.get("related_quote_id") else None + ) + deal.related_project_id = ( + int(request.form.get("related_project_id")) if request.form.get("related_project_id") else None + ) + deal.notes = request.form.get("notes", "").strip() or None + deal.owner_id = int(request.form.get("owner_id")) if request.form.get("owner_id") else current_user.id deal.updated_at = datetime.utcnow() - + if safe_commit(): - flash(_('Deal updated successfully'), 'success') - return redirect(url_for('deals.view_deal', deal_id=deal_id)) + flash(_("Deal updated successfully"), "success") + return redirect(url_for("deals.view_deal", deal_id=deal_id)) except Exception as e: db.session.rollback() - flash(_('Error updating deal: %(error)s', error=str(e)), 'error') - + flash(_("Error updating deal: %(error)s", error=str(e)), "error") + # Get data for form - clients = Client.query.filter_by(status='active').order_by(Client.name).all() + clients = Client.query.filter_by(status="active").order_by(Client.name).all() contacts = Contact.query.filter_by(client_id=deal.client_id, is_active=True).all() if deal.client_id else [] - quotes = Quote.query.filter_by(status='sent').order_by(Quote.created_at.desc()).all() - - return render_template('deals/form.html', - deal=deal, - clients=clients, - contacts=contacts, - quotes=quotes, - pipeline_stages=PIPELINE_STAGES) - -@deals_bp.route('/deals//close-won', methods=['POST']) + quotes = Quote.query.filter_by(status="sent").order_by(Quote.created_at.desc()).all() + + return render_template( + "deals/form.html", deal=deal, clients=clients, contacts=contacts, quotes=quotes, pipeline_stages=PIPELINE_STAGES + ) + + +@deals_bp.route("/deals//close-won", methods=["POST"]) @login_required def close_won(deal_id): """Close deal as won""" deal = Deal.query.get_or_404(deal_id) - + try: - close_date_str = request.form.get('close_date', '').strip() + close_date_str = request.form.get("close_date", "").strip() close_date = None if close_date_str: try: - close_date = datetime.strptime(close_date_str, '%Y-%m-%d').date() + close_date = datetime.strptime(close_date_str, "%Y-%m-%d").date() except ValueError: pass - + deal.close_won(close_date) - + if safe_commit(): - flash(_('Deal closed as won'), 'success') + flash(_("Deal closed as won"), "success") except Exception as e: db.session.rollback() - flash(_('Error closing deal: %(error)s', error=str(e)), 'error') - - return redirect(url_for('deals.view_deal', deal_id=deal_id)) + flash(_("Error closing deal: %(error)s", error=str(e)), "error") + + return redirect(url_for("deals.view_deal", deal_id=deal_id)) -@deals_bp.route('/deals//close-lost', methods=['POST']) + +@deals_bp.route("/deals//close-lost", methods=["POST"]) @login_required def close_lost(deal_id): """Close deal as lost""" deal = Deal.query.get_or_404(deal_id) - + try: - reason = request.form.get('loss_reason', '').strip() or None - - close_date_str = request.form.get('close_date', '').strip() + reason = request.form.get("loss_reason", "").strip() or None + + close_date_str = request.form.get("close_date", "").strip() close_date = None if close_date_str: try: - close_date = datetime.strptime(close_date_str, '%Y-%m-%d').date() + close_date = datetime.strptime(close_date_str, "%Y-%m-%d").date() except ValueError: pass - + deal.close_lost(reason, close_date) - + if safe_commit(): - flash(_('Deal closed as lost'), 'success') + flash(_("Deal closed as lost"), "success") except Exception as e: db.session.rollback() - flash(_('Error closing deal: %(error)s', error=str(e)), 'error') - - return redirect(url_for('deals.view_deal', deal_id=deal_id)) + flash(_("Error closing deal: %(error)s", error=str(e)), "error") + + return redirect(url_for("deals.view_deal", deal_id=deal_id)) + -@deals_bp.route('/deals//activities/create', methods=['GET', 'POST']) +@deals_bp.route("/deals//activities/create", methods=["GET", "POST"]) @login_required def create_activity(deal_id): """Create an activity for a deal""" deal = Deal.query.get_or_404(deal_id) - - if request.method == 'POST': + + if request.method == "POST": try: - activity_date_str = request.form.get('activity_date', '') + activity_date_str = request.form.get("activity_date", "") activity_date = parse_local_datetime(activity_date_str) if activity_date_str else datetime.utcnow() - - due_date_str = request.form.get('due_date', '') + + due_date_str = request.form.get("due_date", "") due_date = parse_local_datetime(due_date_str) if due_date_str else None - + activity = DealActivity( deal_id=deal_id, - type=request.form.get('type', 'note').strip(), + type=request.form.get("type", "note").strip(), created_by=current_user.id, - subject=request.form.get('subject', '').strip() or None, - description=request.form.get('description', '').strip() or None, + subject=request.form.get("subject", "").strip() or None, + description=request.form.get("description", "").strip() or None, activity_date=activity_date, due_date=due_date, - status=request.form.get('status', 'completed').strip() or 'completed' + status=request.form.get("status", "completed").strip() or "completed", ) - + db.session.add(activity) - + if safe_commit(): - flash(_('Activity recorded successfully'), 'success') - return redirect(url_for('deals.view_deal', deal_id=deal_id)) + flash(_("Activity recorded successfully"), "success") + return redirect(url_for("deals.view_deal", deal_id=deal_id)) except Exception as e: db.session.rollback() - flash(_('Error recording activity: %(error)s', error=str(e)), 'error') - - return render_template('deals/activity_form.html', deal=deal, activity=None) + flash(_("Error recording activity: %(error)s", error=str(e)), "error") + + return render_template("deals/activity_form.html", deal=deal, activity=None) + -@deals_bp.route('/api/deals//contacts') +@deals_bp.route("/api/deals//contacts") @login_required def get_deal_contacts(deal_id): """API endpoint to get contacts for a deal's client""" deal = Deal.query.get_or_404(deal_id) - + if not deal.client_id: - return jsonify({'contacts': []}) - - contacts = Contact.query.filter_by(client_id=deal.client_id, is_active=True).all() - return jsonify({'contacts': [c.to_dict() for c in contacts]}) + return jsonify({"contacts": []}) + contacts = Contact.query.filter_by(client_id=deal.client_id, is_active=True).all() + return jsonify({"contacts": [c.to_dict() for c in contacts]}) diff --git a/app/routes/expense_categories.py b/app/routes/expense_categories.py index 6be092b6..4a80cf3d 100644 --- a/app/routes/expense_categories.py +++ b/app/routes/expense_categories.py @@ -8,60 +8,61 @@ from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required -expense_categories_bp = Blueprint('expense_categories', __name__) +expense_categories_bp = Blueprint("expense_categories", __name__) -@expense_categories_bp.route('/expense-categories') +@expense_categories_bp.route("/expense-categories") @login_required -@admin_or_permission_required('expense_categories.view') +@admin_or_permission_required("expense_categories.view") def list_categories(): """List all expense categories""" from app import track_page_view + track_page_view("expense_categories_list") - + categories = ExpenseCategory.query.order_by(ExpenseCategory.name).all() - + # Get budget utilization for each category for category in categories: - category.monthly_utilization = category.get_budget_utilization('monthly') - category.yearly_utilization = category.get_budget_utilization('yearly') - - return render_template('expense_categories/list.html', categories=categories) + category.monthly_utilization = category.get_budget_utilization("monthly") + category.yearly_utilization = category.get_budget_utilization("yearly") + return render_template("expense_categories/list.html", categories=categories) -@expense_categories_bp.route('/expense-categories/create', methods=['GET', 'POST']) + +@expense_categories_bp.route("/expense-categories/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('expense_categories.create') +@admin_or_permission_required("expense_categories.create") def create_category(): """Create a new expense category""" - if request.method == 'GET': - return render_template('expense_categories/form.html', category=None) - + if request.method == "GET": + return render_template("expense_categories/form.html", category=None) + try: # Get form data - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - code = request.form.get('code', '').strip() - color = request.form.get('color', '').strip() - icon = request.form.get('icon', '').strip() - + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + code = request.form.get("code", "").strip() + color = request.form.get("color", "").strip() + icon = request.form.get("icon", "").strip() + # Validate required fields if not name: - flash(_('Category name is required'), 'error') - return redirect(url_for('expense_categories.create_category')) - + flash(_("Category name is required"), "error") + return redirect(url_for("expense_categories.create_category")) + # Budget fields - monthly_budget = request.form.get('monthly_budget', '').strip() - quarterly_budget = request.form.get('quarterly_budget', '').strip() - yearly_budget = request.form.get('yearly_budget', '').strip() - budget_threshold_percent = request.form.get('budget_threshold_percent', '80') - + monthly_budget = request.form.get("monthly_budget", "").strip() + quarterly_budget = request.form.get("quarterly_budget", "").strip() + yearly_budget = request.form.get("yearly_budget", "").strip() + budget_threshold_percent = request.form.get("budget_threshold_percent", "80") + # Settings - requires_receipt = request.form.get('requires_receipt') == 'on' - requires_approval = request.form.get('requires_approval') == 'on' - default_tax_rate = request.form.get('default_tax_rate', '').strip() - is_active = request.form.get('is_active') == 'on' - + requires_receipt = request.form.get("requires_receipt") == "on" + requires_approval = request.form.get("requires_approval") == "on" + default_tax_rate = request.form.get("default_tax_rate", "").strip() + is_active = request.form.get("is_active") == "on" + # Create category category = ExpenseCategory( name=name, @@ -76,178 +77,176 @@ def create_category(): requires_receipt=requires_receipt, requires_approval=requires_approval, default_tax_rate=Decimal(default_tax_rate) if default_tax_rate else None, - is_active=is_active + is_active=is_active, ) - + db.session.add(category) - + if safe_commit(db): - flash(_('Expense category created successfully'), 'success') - log_event('expense_category_created', user_id=current_user.id, category_id=category.id) - track_event(current_user.id, 'expense_category.created', {'category_id': category.id}) - return redirect(url_for('expense_categories.list_categories')) + flash(_("Expense category created successfully"), "success") + log_event("expense_category_created", user_id=current_user.id, category_id=category.id) + track_event(current_user.id, "expense_category.created", {"category_id": category.id}) + return redirect(url_for("expense_categories.list_categories")) else: - flash(_('Error creating expense category'), 'error') - return redirect(url_for('expense_categories.create_category')) - + flash(_("Error creating expense category"), "error") + return redirect(url_for("expense_categories.create_category")) + except Exception as e: from flask import current_app + current_app.logger.error(f"Error creating expense category: {e}") - flash(_('Error creating expense category'), 'error') - return redirect(url_for('expense_categories.create_category')) + flash(_("Error creating expense category"), "error") + return redirect(url_for("expense_categories.create_category")) -@expense_categories_bp.route('/expense-categories/') +@expense_categories_bp.route("/expense-categories/") @login_required -@admin_or_permission_required('expense_categories.view') +@admin_or_permission_required("expense_categories.view") def view_category(category_id): """View expense category details""" category = ExpenseCategory.query.get_or_404(category_id) - + from app import track_page_view - track_page_view("expense_category_detail", properties={'category_id': category_id}) - + + track_page_view("expense_category_detail", properties={"category_id": category_id}) + # Get budget utilization - monthly_util = category.get_budget_utilization('monthly') - quarterly_util = category.get_budget_utilization('quarterly') - yearly_util = category.get_budget_utilization('yearly') - + monthly_util = category.get_budget_utilization("monthly") + quarterly_util = category.get_budget_utilization("quarterly") + yearly_util = category.get_budget_utilization("yearly") + return render_template( - 'expense_categories/view.html', + "expense_categories/view.html", category=category, monthly_utilization=monthly_util, quarterly_utilization=quarterly_util, - yearly_utilization=yearly_util + yearly_utilization=yearly_util, ) -@expense_categories_bp.route('/expense-categories//edit', methods=['GET', 'POST']) +@expense_categories_bp.route("/expense-categories//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('expense_categories.update') +@admin_or_permission_required("expense_categories.update") def edit_category(category_id): """Edit an expense category""" category = ExpenseCategory.query.get_or_404(category_id) - - if request.method == 'GET': - return render_template('expense_categories/form.html', category=category) - + + if request.method == "GET": + return render_template("expense_categories/form.html", category=category) + try: # Get form data - name = request.form.get('name', '').strip() + name = request.form.get("name", "").strip() if not name: - flash(_('Category name is required'), 'error') - return redirect(url_for('expense_categories.edit_category', category_id=category_id)) - + flash(_("Category name is required"), "error") + return redirect(url_for("expense_categories.edit_category", category_id=category_id)) + # Update category fields category.name = name - category.description = request.form.get('description', '').strip() - category.code = request.form.get('code', '').strip() or None - category.color = request.form.get('color', '').strip() or None - category.icon = request.form.get('icon', '').strip() or None - + category.description = request.form.get("description", "").strip() + category.code = request.form.get("code", "").strip() or None + category.color = request.form.get("color", "").strip() or None + category.icon = request.form.get("icon", "").strip() or None + # Budget fields - monthly_budget = request.form.get('monthly_budget', '').strip() - quarterly_budget = request.form.get('quarterly_budget', '').strip() - yearly_budget = request.form.get('yearly_budget', '').strip() - + monthly_budget = request.form.get("monthly_budget", "").strip() + quarterly_budget = request.form.get("quarterly_budget", "").strip() + yearly_budget = request.form.get("yearly_budget", "").strip() + category.monthly_budget = Decimal(monthly_budget) if monthly_budget else None category.quarterly_budget = Decimal(quarterly_budget) if quarterly_budget else None category.yearly_budget = Decimal(yearly_budget) if yearly_budget else None - category.budget_threshold_percent = int(request.form.get('budget_threshold_percent', '80')) - + category.budget_threshold_percent = int(request.form.get("budget_threshold_percent", "80")) + # Settings - category.requires_receipt = request.form.get('requires_receipt') == 'on' - category.requires_approval = request.form.get('requires_approval') == 'on' - - default_tax_rate = request.form.get('default_tax_rate', '').strip() + category.requires_receipt = request.form.get("requires_receipt") == "on" + category.requires_approval = request.form.get("requires_approval") == "on" + + default_tax_rate = request.form.get("default_tax_rate", "").strip() category.default_tax_rate = Decimal(default_tax_rate) if default_tax_rate else None - category.is_active = request.form.get('is_active') == 'on' - + category.is_active = request.form.get("is_active") == "on" + category.updated_at = datetime.utcnow() - + if safe_commit(db): - flash(_('Expense category updated successfully'), 'success') - log_event('expense_category_updated', user_id=current_user.id, category_id=category.id) - track_event(current_user.id, 'expense_category.updated', {'category_id': category.id}) - return redirect(url_for('expense_categories.view_category', category_id=category.id)) + flash(_("Expense category updated successfully"), "success") + log_event("expense_category_updated", user_id=current_user.id, category_id=category.id) + track_event(current_user.id, "expense_category.updated", {"category_id": category.id}) + return redirect(url_for("expense_categories.view_category", category_id=category.id)) else: - flash(_('Error updating expense category'), 'error') - return redirect(url_for('expense_categories.edit_category', category_id=category_id)) - + flash(_("Error updating expense category"), "error") + return redirect(url_for("expense_categories.edit_category", category_id=category_id)) + except Exception as e: from flask import current_app + current_app.logger.error(f"Error updating expense category: {e}") - flash(_('Error updating expense category'), 'error') - return redirect(url_for('expense_categories.edit_category', category_id=category_id)) + flash(_("Error updating expense category"), "error") + return redirect(url_for("expense_categories.edit_category", category_id=category_id)) -@expense_categories_bp.route('/expense-categories//delete', methods=['POST']) +@expense_categories_bp.route("/expense-categories//delete", methods=["POST"]) @login_required -@admin_or_permission_required('expense_categories.delete') +@admin_or_permission_required("expense_categories.delete") def delete_category(category_id): """Delete an expense category""" category = ExpenseCategory.query.get_or_404(category_id) - + try: # Instead of deleting, just deactivate category.is_active = False category.updated_at = datetime.utcnow() - + if safe_commit(db): - flash(_('Expense category deactivated successfully'), 'success') - log_event('expense_category_deleted', user_id=current_user.id, category_id=category_id) - track_event(current_user.id, 'expense_category.deleted', {'category_id': category_id}) + flash(_("Expense category deactivated successfully"), "success") + log_event("expense_category_deleted", user_id=current_user.id, category_id=category_id) + track_event(current_user.id, "expense_category.deleted", {"category_id": category_id}) else: - flash(_('Error deactivating expense category'), 'error') - + flash(_("Error deactivating expense category"), "error") + except Exception as e: from flask import current_app + current_app.logger.error(f"Error deactivating expense category: {e}") - flash(_('Error deactivating expense category'), 'error') - - return redirect(url_for('expense_categories.list_categories')) + flash(_("Error deactivating expense category"), "error") + + return redirect(url_for("expense_categories.list_categories")) # API endpoints -@expense_categories_bp.route('/api/expense-categories', methods=['GET']) +@expense_categories_bp.route("/api/expense-categories", methods=["GET"]) @login_required def api_list_categories(): """API endpoint to list expense categories""" categories = ExpenseCategory.get_active_categories() - - return jsonify({ - 'categories': [category.to_dict() for category in categories], - 'count': len(categories) - }) + return jsonify({"categories": [category.to_dict() for category in categories], "count": len(categories)}) -@expense_categories_bp.route('/api/expense-categories/', methods=['GET']) + +@expense_categories_bp.route("/api/expense-categories/", methods=["GET"]) @login_required def api_get_category(category_id): """API endpoint to get a single expense category""" category = ExpenseCategory.query.get_or_404(category_id) - + return jsonify(category.to_dict()) -@expense_categories_bp.route('/api/expense-categories/budget-alerts', methods=['GET']) +@expense_categories_bp.route("/api/expense-categories/budget-alerts", methods=["GET"]) @login_required -@admin_or_permission_required('expense_categories.view') +@admin_or_permission_required("expense_categories.view") def api_budget_alerts(): """API endpoint to get categories over budget threshold""" - period = request.args.get('period', 'monthly') - + period = request.args.get("period", "monthly") + over_budget = ExpenseCategory.get_categories_over_budget(period) - - return jsonify({ - 'period': period, - 'alerts': [ - { - 'category': item['category'].to_dict(), - 'utilization': item['utilization'] - } - for item in over_budget - ], - 'count': len(over_budget) - }) + return jsonify( + { + "period": period, + "alerts": [ + {"category": item["category"].to_dict(), "utilization": item["utilization"]} for item in over_budget + ], + "count": len(over_budget), + } + ) diff --git a/app/routes/expenses.py b/app/routes/expenses.py index 87394537..d2e5c878 100644 --- a/app/routes/expenses.py +++ b/app/routes/expenses.py @@ -13,83 +13,79 @@ from werkzeug.utils import secure_filename import json -expenses_bp = Blueprint('expenses', __name__) +expenses_bp = Blueprint("expenses", __name__) # File upload configuration -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'} -UPLOAD_FOLDER = 'uploads/receipts' +ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf"} +UPLOAD_FOLDER = "uploads/receipts" def allowed_file(filename): """Check if file extension is allowed""" - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS -@expenses_bp.route('/expenses') +@expenses_bp.route("/expenses") @login_required def list_expenses(): """List all expenses with filters""" # Track page view from app import track_page_view + track_page_view("expenses_list") - - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 25, type=int) - + + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 25, type=int) + # Filter parameters - status = request.args.get('status', '').strip() - category = request.args.get('category', '').strip() - project_id = request.args.get('project_id', type=int) - client_id = request.args.get('client_id', type=int) - user_id = request.args.get('user_id', type=int) - start_date = request.args.get('start_date', '').strip() - end_date = request.args.get('end_date', '').strip() - search = request.args.get('search', '').strip() - billable = request.args.get('billable', '').strip() - reimbursable = request.args.get('reimbursable', '').strip() - + status = request.args.get("status", "").strip() + category = request.args.get("category", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + user_id = request.args.get("user_id", type=int) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + search = request.args.get("search", "").strip() + billable = request.args.get("billable", "").strip() + reimbursable = request.args.get("reimbursable", "").strip() + # Build query query = Expense.query - + # Non-admin users can only see their own expenses or expenses they approved if not current_user.is_admin: - query = query.filter( - db.or_( - Expense.user_id == current_user.id, - Expense.approved_by == current_user.id - ) - ) - + query = query.filter(db.or_(Expense.user_id == current_user.id, Expense.approved_by == current_user.id)) + # Apply filters if status: query = query.filter(Expense.status == status) - + if category: query = query.filter(Expense.category == category) - + if project_id: query = query.filter(Expense.project_id == project_id) - + if client_id: query = query.filter(Expense.client_id == client_id) - + if user_id and current_user.is_admin: query = query.filter(Expense.user_id == user_id) - + if start_date: try: - start = datetime.strptime(start_date, '%Y-%m-%d').date() + start = datetime.strptime(start_date, "%Y-%m-%d").date() query = query.filter(Expense.expense_date >= start) except ValueError: pass - + if end_date: try: - end = datetime.strptime(end_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() query = query.filter(Expense.expense_date <= end) except ValueError: pass - + if search: like = f"%{search}%" query = query.filter( @@ -97,46 +93,42 @@ def list_expenses(): Expense.title.ilike(like), Expense.description.ilike(like), Expense.vendor.ilike(like), - Expense.notes.ilike(like) + Expense.notes.ilike(like), ) ) - - if billable == 'true': + + if billable == "true": query = query.filter(Expense.billable == True) - elif billable == 'false': + elif billable == "false": query = query.filter(Expense.billable == False) - - if reimbursable == 'true': + + if reimbursable == "true": query = query.filter(Expense.reimbursable == True) - elif reimbursable == 'false': + elif reimbursable == "false": query = query.filter(Expense.reimbursable == False) - + # Paginate expenses_pagination = query.order_by(Expense.expense_date.desc()).paginate( - page=page, - per_page=per_page, - error_out=False + page=page, per_page=per_page, error_out=False ) - + # Get filter options - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() categories = Expense.get_expense_categories() - + # Get users for admin filter users = [] if current_user.is_admin: users = User.query.filter_by(is_active=True).order_by(User.username).all() - + # Calculate totals for current filters (without pagination) total_amount = 0 total_count = query.count() - + if total_count > 0: - total_query = db.session.query( - db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0)) - ) - + total_query = db.session.query(db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))) + # Apply same filters if status: total_query = total_query.filter(Expense.status == status) @@ -150,30 +142,27 @@ def list_expenses(): total_query = total_query.filter(Expense.user_id == user_id) if start_date: try: - start = datetime.strptime(start_date, '%Y-%m-%d').date() + start = datetime.strptime(start_date, "%Y-%m-%d").date() total_query = total_query.filter(Expense.expense_date >= start) except ValueError: pass if end_date: try: - end = datetime.strptime(end_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() total_query = total_query.filter(Expense.expense_date <= end) except ValueError: pass - + # Non-admin users restriction if not current_user.is_admin: total_query = total_query.filter( - db.or_( - Expense.user_id == current_user.id, - Expense.approved_by == current_user.id - ) + db.or_(Expense.user_id == current_user.id, Expense.approved_by == current_user.id) ) - + total_amount = total_query.scalar() or 0 - + return render_template( - 'expenses/list.html', + "expenses/list.html", expenses=expenses_pagination.items, pagination=expenses_pagination, projects=projects, @@ -192,110 +181,110 @@ def list_expenses(): end_date=end_date, search=search, billable=billable, - reimbursable=reimbursable + reimbursable=reimbursable, ) -@expenses_bp.route('/expenses/create', methods=['GET', 'POST']) +@expenses_bp.route("/expenses/create", methods=["GET", "POST"]) @login_required def create_expense(): """Create a new expense""" - if request.method == 'GET': + if request.method == "GET": # Get data for form - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() categories = Expense.get_expense_categories() payment_methods = Expense.get_payment_methods() - + return render_template( - 'expenses/form.html', + "expenses/form.html", expense=None, projects=projects, clients=clients, categories=categories, - payment_methods=payment_methods + payment_methods=payment_methods, ) - + try: # Get form data - title = request.form.get('title', '').strip() - description = request.form.get('description', '').strip() - category = request.form.get('category', '').strip() - amount = request.form.get('amount', '0').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - tax_amount = request.form.get('tax_amount', '0').strip() - expense_date = request.form.get('expense_date', '').strip() - + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + amount = request.form.get("amount", "0").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + tax_amount = request.form.get("tax_amount", "0").strip() + expense_date = request.form.get("expense_date", "").strip() + # Validate required fields if not title: - flash(_('Title is required'), 'error') - return redirect(url_for('expenses.create_expense')) - + flash(_("Title is required"), "error") + return redirect(url_for("expenses.create_expense")) + if not category: - flash(_('Category is required'), 'error') - return redirect(url_for('expenses.create_expense')) - + flash(_("Category is required"), "error") + return redirect(url_for("expenses.create_expense")) + if not amount: - flash(_('Amount is required'), 'error') - return redirect(url_for('expenses.create_expense')) - + flash(_("Amount is required"), "error") + return redirect(url_for("expenses.create_expense")) + if not expense_date: - flash(_('Expense date is required'), 'error') - return redirect(url_for('expenses.create_expense')) - + flash(_("Expense date is required"), "error") + return redirect(url_for("expenses.create_expense")) + # Parse date try: - expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date() + expense_date_obj = datetime.strptime(expense_date, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('expenses.create_expense')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("expenses.create_expense")) + # Parse amounts try: amount_decimal = Decimal(amount) - tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0') + tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal("0") except (ValueError, Decimal.InvalidOperation): - flash(_('Invalid amount format'), 'error') - return redirect(url_for('expenses.create_expense')) - + flash(_("Invalid amount format"), "error") + return redirect(url_for("expenses.create_expense")) + # Optional fields - project_id = request.form.get('project_id', type=int) - client_id = request.form.get('client_id', type=int) - payment_method = request.form.get('payment_method', '').strip() - payment_date = request.form.get('payment_date', '').strip() - vendor = request.form.get('vendor', '').strip() - receipt_number = request.form.get('receipt_number', '').strip() - notes = request.form.get('notes', '').strip() - tags = request.form.get('tags', '').strip() - billable = request.form.get('billable') == 'on' - reimbursable = request.form.get('reimbursable') == 'on' - + project_id = request.form.get("project_id", type=int) + client_id = request.form.get("client_id", type=int) + payment_method = request.form.get("payment_method", "").strip() + payment_date = request.form.get("payment_date", "").strip() + vendor = request.form.get("vendor", "").strip() + receipt_number = request.form.get("receipt_number", "").strip() + notes = request.form.get("notes", "").strip() + tags = request.form.get("tags", "").strip() + billable = request.form.get("billable") == "on" + reimbursable = request.form.get("reimbursable") == "on" + # Parse payment date if provided payment_date_obj = None if payment_date: try: - payment_date_obj = datetime.strptime(payment_date, '%Y-%m-%d').date() + payment_date_obj = datetime.strptime(payment_date, "%Y-%m-%d").date() except ValueError: pass - + # Handle file upload receipt_path = None - if 'receipt_file' in request.files: - file = request.files['receipt_file'] + if "receipt_file" in request.files: + file = request.files["receipt_file"] if file and file.filename and allowed_file(file.filename): filename = secure_filename(file.filename) # Add timestamp to filename to avoid collisions - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{timestamp}_{filename}" - + # Ensure upload directory exists - upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER) + upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER) os.makedirs(upload_dir, exist_ok=True) - + file_path = os.path.join(upload_dir, filename) file.save(file_path) receipt_path = os.path.join(UPLOAD_FOLDER, filename) - + # Create expense expense = Expense( user_id=current_user.id, @@ -316,111 +305,116 @@ def create_expense(): notes=notes, tags=tags, billable=billable, - reimbursable=reimbursable + reimbursable=reimbursable, ) - + db.session.add(expense) - + if safe_commit(db): - flash(_('Expense created successfully'), 'success') - log_event('expense_created', user_id=current_user.id, expense_id=expense.id) - track_event(current_user.id, 'expense.created', { - 'expense_id': expense.id, - 'category': category, - 'amount': float(amount_decimal), - 'billable': billable, - 'reimbursable': reimbursable - }) - return redirect(url_for('expenses.view_expense', expense_id=expense.id)) + flash(_("Expense created successfully"), "success") + log_event("expense_created", user_id=current_user.id, expense_id=expense.id) + track_event( + current_user.id, + "expense.created", + { + "expense_id": expense.id, + "category": category, + "amount": float(amount_decimal), + "billable": billable, + "reimbursable": reimbursable, + }, + ) + return redirect(url_for("expenses.view_expense", expense_id=expense.id)) else: - flash(_('Error creating expense'), 'error') - return redirect(url_for('expenses.create_expense')) - + flash(_("Error creating expense"), "error") + return redirect(url_for("expenses.create_expense")) + except Exception as e: current_app.logger.error(f"Error creating expense: {e}") - flash(_('Error creating expense'), 'error') - return redirect(url_for('expenses.create_expense')) + flash(_("Error creating expense"), "error") + return redirect(url_for("expenses.create_expense")) -@expenses_bp.route('/expenses/') +@expenses_bp.route("/expenses/") @login_required def view_expense(expense_id): """View expense details""" expense = Expense.query.get_or_404(expense_id) - + # Check permission if not current_user.is_admin and expense.user_id != current_user.id and expense.approved_by != current_user.id: - flash(_('You do not have permission to view this expense'), 'error') - return redirect(url_for('expenses.list_expenses')) - + flash(_("You do not have permission to view this expense"), "error") + return redirect(url_for("expenses.list_expenses")) + # Track page view from app import track_page_view - track_page_view("expense_detail", properties={'expense_id': expense_id}) - - return render_template('expenses/view.html', expense=expense) + track_page_view("expense_detail", properties={"expense_id": expense_id}) + + return render_template("expenses/view.html", expense=expense) -@expenses_bp.route('/expenses//edit', methods=['GET', 'POST']) + +@expenses_bp.route("/expenses//edit", methods=["GET", "POST"]) @login_required def edit_expense(expense_id): """Edit an existing expense""" expense = Expense.query.get_or_404(expense_id) - + # Check permission - only owner can edit (unless admin) if not current_user.is_admin and expense.user_id != current_user.id: - flash(_('You do not have permission to edit this expense'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + flash(_("You do not have permission to edit this expense"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + # Cannot edit approved or reimbursed expenses without admin privileges - if not current_user.is_admin and expense.status in ['approved', 'reimbursed']: - flash(_('Cannot edit approved or reimbursed expenses'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - - if request.method == 'GET': - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + if not current_user.is_admin and expense.status in ["approved", "reimbursed"]: + flash(_("Cannot edit approved or reimbursed expenses"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + + if request.method == "GET": + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() categories = Expense.get_expense_categories() payment_methods = Expense.get_payment_methods() - + return render_template( - 'expenses/form.html', + "expenses/form.html", expense=expense, projects=projects, clients=clients, categories=categories, - payment_methods=payment_methods + payment_methods=payment_methods, ) - + try: # Get form data - title = request.form.get('title', '').strip() - description = request.form.get('description', '').strip() - category = request.form.get('category', '').strip() - amount = request.form.get('amount', '0').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - tax_amount = request.form.get('tax_amount', '0').strip() - expense_date = request.form.get('expense_date', '').strip() - + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + amount = request.form.get("amount", "0").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + tax_amount = request.form.get("tax_amount", "0").strip() + expense_date = request.form.get("expense_date", "").strip() + # Validate required fields if not title or not category or not amount or not expense_date: - flash(_('Please fill in all required fields'), 'error') - return redirect(url_for('expenses.edit_expense', expense_id=expense_id)) - + flash(_("Please fill in all required fields"), "error") + return redirect(url_for("expenses.edit_expense", expense_id=expense_id)) + # Parse date try: - expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date() + expense_date_obj = datetime.strptime(expense_date, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('expenses.edit_expense', expense_id=expense_id)) - + flash(_("Invalid date format"), "error") + return redirect(url_for("expenses.edit_expense", expense_id=expense_id)) + # Parse amounts try: amount_decimal = Decimal(amount) - tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0') + tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal("0") except (ValueError, Decimal.InvalidOperation): - flash(_('Invalid amount format'), 'error') - return redirect(url_for('expenses.edit_expense', expense_id=expense_id)) - + flash(_("Invalid amount format"), "error") + return redirect(url_for("expenses.edit_expense", expense_id=expense_id)) + # Update expense fields expense.title = title expense.description = description @@ -429,352 +423,359 @@ def edit_expense(expense_id): expense.currency_code = currency_code expense.tax_amount = tax_amount_decimal expense.expense_date = expense_date_obj - + # Optional fields - expense.project_id = request.form.get('project_id', type=int) - expense.client_id = request.form.get('client_id', type=int) - expense.payment_method = request.form.get('payment_method', '').strip() - expense.vendor = request.form.get('vendor', '').strip() - expense.receipt_number = request.form.get('receipt_number', '').strip() - expense.notes = request.form.get('notes', '').strip() - expense.tags = request.form.get('tags', '').strip() - expense.billable = request.form.get('billable') == 'on' - expense.reimbursable = request.form.get('reimbursable') == 'on' - + expense.project_id = request.form.get("project_id", type=int) + expense.client_id = request.form.get("client_id", type=int) + expense.payment_method = request.form.get("payment_method", "").strip() + expense.vendor = request.form.get("vendor", "").strip() + expense.receipt_number = request.form.get("receipt_number", "").strip() + expense.notes = request.form.get("notes", "").strip() + expense.tags = request.form.get("tags", "").strip() + expense.billable = request.form.get("billable") == "on" + expense.reimbursable = request.form.get("reimbursable") == "on" + # Parse payment date if provided - payment_date = request.form.get('payment_date', '').strip() + payment_date = request.form.get("payment_date", "").strip() if payment_date: try: - expense.payment_date = datetime.strptime(payment_date, '%Y-%m-%d').date() + expense.payment_date = datetime.strptime(payment_date, "%Y-%m-%d").date() except ValueError: expense.payment_date = None else: expense.payment_date = None - + # Handle file upload - if 'receipt_file' in request.files: - file = request.files['receipt_file'] + if "receipt_file" in request.files: + file = request.files["receipt_file"] if file and file.filename and allowed_file(file.filename): filename = secure_filename(file.filename) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{timestamp}_{filename}" - - upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER) + + upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER) os.makedirs(upload_dir, exist_ok=True) - + file_path = os.path.join(upload_dir, filename) file.save(file_path) - + # Delete old receipt if exists if expense.receipt_path: - old_file_path = os.path.join(current_app.root_path, '..', expense.receipt_path) + old_file_path = os.path.join(current_app.root_path, "..", expense.receipt_path) if os.path.exists(old_file_path): try: os.remove(old_file_path) except Exception: pass - + expense.receipt_path = os.path.join(UPLOAD_FOLDER, filename) - + expense.updated_at = datetime.utcnow() - + if safe_commit(db): - flash(_('Expense updated successfully'), 'success') - log_event('expense_updated', user_id=current_user.id, expense_id=expense.id) - track_event(current_user.id, 'expense.updated', {'expense_id': expense.id}) - return redirect(url_for('expenses.view_expense', expense_id=expense.id)) + flash(_("Expense updated successfully"), "success") + log_event("expense_updated", user_id=current_user.id, expense_id=expense.id) + track_event(current_user.id, "expense.updated", {"expense_id": expense.id}) + return redirect(url_for("expenses.view_expense", expense_id=expense.id)) else: - flash(_('Error updating expense'), 'error') - return redirect(url_for('expenses.edit_expense', expense_id=expense_id)) - + flash(_("Error updating expense"), "error") + return redirect(url_for("expenses.edit_expense", expense_id=expense_id)) + except Exception as e: current_app.logger.error(f"Error updating expense: {e}") - flash(_('Error updating expense'), 'error') - return redirect(url_for('expenses.edit_expense', expense_id=expense_id)) + flash(_("Error updating expense"), "error") + return redirect(url_for("expenses.edit_expense", expense_id=expense_id)) -@expenses_bp.route('/expenses//delete', methods=['POST']) +@expenses_bp.route("/expenses//delete", methods=["POST"]) @login_required def delete_expense(expense_id): """Delete an expense""" expense = Expense.query.get_or_404(expense_id) - + # Check permission if not current_user.is_admin and expense.user_id != current_user.id: - flash(_('You do not have permission to delete this expense'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + flash(_("You do not have permission to delete this expense"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + # Cannot delete approved or invoiced expenses without admin privileges - if not current_user.is_admin and (expense.status == 'approved' or expense.invoiced): - flash(_('Cannot delete approved or invoiced expenses'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + if not current_user.is_admin and (expense.status == "approved" or expense.invoiced): + flash(_("Cannot delete approved or invoiced expenses"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + try: # Delete receipt file if exists if expense.receipt_path: - file_path = os.path.join(current_app.root_path, '..', expense.receipt_path) + file_path = os.path.join(current_app.root_path, "..", expense.receipt_path) if os.path.exists(file_path): try: os.remove(file_path) except Exception: pass - + db.session.delete(expense) - + if safe_commit(db): - flash(_('Expense deleted successfully'), 'success') - log_event('expense_deleted', user_id=current_user.id, expense_id=expense_id) - track_event(current_user.id, 'expense.deleted', {'expense_id': expense_id}) + flash(_("Expense deleted successfully"), "success") + log_event("expense_deleted", user_id=current_user.id, expense_id=expense_id) + track_event(current_user.id, "expense.deleted", {"expense_id": expense_id}) else: - flash(_('Error deleting expense'), 'error') - + flash(_("Error deleting expense"), "error") + except Exception as e: current_app.logger.error(f"Error deleting expense: {e}") - flash(_('Error deleting expense'), 'error') - - return redirect(url_for('expenses.list_expenses')) + flash(_("Error deleting expense"), "error") -@expenses_bp.route('/expenses/bulk-delete', methods=['POST']) + return redirect(url_for("expenses.list_expenses")) + + +@expenses_bp.route("/expenses/bulk-delete", methods=["POST"]) @login_required def bulk_delete_expenses(): """Delete multiple expenses at once""" - expense_ids = request.form.getlist('expense_ids[]') - + expense_ids = request.form.getlist("expense_ids[]") + if not expense_ids: - flash(_('No expenses selected for deletion'), 'warning') - return redirect(url_for('expenses.list_expenses')) - + flash(_("No expenses selected for deletion"), "warning") + return redirect(url_for("expenses.list_expenses")) + deleted_count = 0 skipped_count = 0 errors = [] - + for expense_id_str in expense_ids: try: expense_id = int(expense_id_str) expense = Expense.query.get(expense_id) - + if not expense: continue - + # Check permissions if not current_user.is_admin and expense.user_id != current_user.id: skipped_count += 1 errors.append(f"'{expense.title or expense_id_str}': No permission") continue - + # Cannot delete approved or invoiced expenses without admin privileges - if not current_user.is_admin and (expense.status == 'approved' or expense.invoiced): + if not current_user.is_admin and (expense.status == "approved" or expense.invoiced): skipped_count += 1 errors.append(f"'{expense.title or expense_id_str}': Approved or invoiced") continue - + # Delete receipt file if exists if expense.receipt_path: - file_path = os.path.join(current_app.root_path, '..', expense.receipt_path) + file_path = os.path.join(current_app.root_path, "..", expense.receipt_path) if os.path.exists(file_path): try: os.remove(file_path) except Exception: pass - + expense_title = expense.title or str(expense_id) db.session.delete(expense) deleted_count += 1 - + except Exception as e: skipped_count += 1 errors.append(f"ID {expense_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: if not safe_commit(db): - flash(_('Could not delete expenses due to a database error. Please check server logs.'), 'error') - return redirect(url_for('expenses.list_expenses')) - + flash(_("Could not delete expenses due to a database error. Please check server logs."), "error") + return redirect(url_for("expenses.list_expenses")) + # Show appropriate messages if deleted_count > 0: - flash(_('Successfully deleted %(count)d expense(s)', count=deleted_count), 'success') - + flash(_("Successfully deleted %(count)d expense(s)", count=deleted_count), "success") + if skipped_count > 0: - flash(_('Skipped %(count)d expense(s): %(errors)s', count=skipped_count, errors="; ".join(errors[:3])), 'warning') - - return redirect(url_for('expenses.list_expenses')) + flash( + _("Skipped %(count)d expense(s): %(errors)s", count=skipped_count, errors="; ".join(errors[:3])), "warning" + ) + + return redirect(url_for("expenses.list_expenses")) + -@expenses_bp.route('/expenses/bulk-status', methods=['POST']) +@expenses_bp.route("/expenses/bulk-status", methods=["POST"]) @login_required def bulk_update_status(): """Update status for multiple expenses at once""" - expense_ids = request.form.getlist('expense_ids[]') - new_status = request.form.get('status', '').strip() - + expense_ids = request.form.getlist("expense_ids[]") + new_status = request.form.get("status", "").strip() + if not expense_ids: - flash(_('No expenses selected'), 'warning') - return redirect(url_for('expenses.list_expenses')) - + flash(_("No expenses selected"), "warning") + return redirect(url_for("expenses.list_expenses")) + # Validate status - valid_statuses = ['pending', 'approved', 'rejected', 'reimbursed'] + valid_statuses = ["pending", "approved", "rejected", "reimbursed"] if not new_status or new_status not in valid_statuses: - flash(_('Invalid status value'), 'error') - return redirect(url_for('expenses.list_expenses')) - + flash(_("Invalid status value"), "error") + return redirect(url_for("expenses.list_expenses")) + updated_count = 0 skipped_count = 0 - + for expense_id_str in expense_ids: try: expense_id = int(expense_id_str) expense = Expense.query.get(expense_id) - + if not expense: continue - + # Check permissions - non-admin users can only update their own expenses if not current_user.is_admin and expense.user_id != current_user.id: skipped_count += 1 continue - + expense.status = new_status updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: if not safe_commit(db): - flash(_('Could not update expenses due to a database error'), 'error') - return redirect(url_for('expenses.list_expenses')) - - flash(_('Successfully updated %(count)d expense(s) to %(status)s', count=updated_count, status=new_status), 'success') - + flash(_("Could not update expenses due to a database error"), "error") + return redirect(url_for("expenses.list_expenses")) + + flash( + _("Successfully updated %(count)d expense(s) to %(status)s", count=updated_count, status=new_status), + "success", + ) + if skipped_count > 0: - flash(_('Skipped %(count)d expense(s) (no permission)', count=skipped_count), 'warning') - - return redirect(url_for('expenses.list_expenses')) + flash(_("Skipped %(count)d expense(s) (no permission)", count=skipped_count), "warning") + + return redirect(url_for("expenses.list_expenses")) -@expenses_bp.route('/expenses//approve', methods=['POST']) +@expenses_bp.route("/expenses//approve", methods=["POST"]) @login_required def approve_expense(expense_id): """Approve an expense""" if not current_user.is_admin: - flash(_('Only administrators can approve expenses'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + flash(_("Only administrators can approve expenses"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + expense = Expense.query.get_or_404(expense_id) - - if expense.status != 'pending': - flash(_('Only pending expenses can be approved'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + + if expense.status != "pending": + flash(_("Only pending expenses can be approved"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + try: - notes = request.form.get('approval_notes', '').strip() + notes = request.form.get("approval_notes", "").strip() expense.approve(current_user.id, notes) - + if safe_commit(db): - flash(_('Expense approved successfully'), 'success') - log_event('expense_approved', user_id=current_user.id, expense_id=expense_id) - track_event(current_user.id, 'expense.approved', {'expense_id': expense_id}) + flash(_("Expense approved successfully"), "success") + log_event("expense_approved", user_id=current_user.id, expense_id=expense_id) + track_event(current_user.id, "expense.approved", {"expense_id": expense_id}) else: - flash(_('Error approving expense'), 'error') - + flash(_("Error approving expense"), "error") + except Exception as e: current_app.logger.error(f"Error approving expense: {e}") - flash(_('Error approving expense'), 'error') - - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) + flash(_("Error approving expense"), "error") + + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) -@expenses_bp.route('/expenses//reject', methods=['POST']) +@expenses_bp.route("/expenses//reject", methods=["POST"]) @login_required def reject_expense(expense_id): """Reject an expense""" if not current_user.is_admin: - flash(_('Only administrators can reject expenses'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + flash(_("Only administrators can reject expenses"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + expense = Expense.query.get_or_404(expense_id) - - if expense.status != 'pending': - flash(_('Only pending expenses can be rejected'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + + if expense.status != "pending": + flash(_("Only pending expenses can be rejected"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + try: - reason = request.form.get('rejection_reason', '').strip() + reason = request.form.get("rejection_reason", "").strip() if not reason: - flash(_('Rejection reason is required'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + flash(_("Rejection reason is required"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + expense.reject(current_user.id, reason) - + if safe_commit(db): - flash(_('Expense rejected'), 'success') - log_event('expense_rejected', user_id=current_user.id, expense_id=expense_id) - track_event(current_user.id, 'expense.rejected', {'expense_id': expense_id}) + flash(_("Expense rejected"), "success") + log_event("expense_rejected", user_id=current_user.id, expense_id=expense_id) + track_event(current_user.id, "expense.rejected", {"expense_id": expense_id}) else: - flash(_('Error rejecting expense'), 'error') - + flash(_("Error rejecting expense"), "error") + except Exception as e: current_app.logger.error(f"Error rejecting expense: {e}") - flash(_('Error rejecting expense'), 'error') - - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) + flash(_("Error rejecting expense"), "error") + + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) -@expenses_bp.route('/expenses//reimburse', methods=['POST']) +@expenses_bp.route("/expenses//reimburse", methods=["POST"]) @login_required def mark_reimbursed(expense_id): """Mark an expense as reimbursed""" if not current_user.is_admin: - flash(_('Only administrators can mark expenses as reimbursed'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + flash(_("Only administrators can mark expenses as reimbursed"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + expense = Expense.query.get_or_404(expense_id) - - if expense.status != 'approved': - flash(_('Only approved expenses can be marked as reimbursed'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + + if expense.status != "approved": + flash(_("Only approved expenses can be marked as reimbursed"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + if not expense.reimbursable: - flash(_('This expense is not marked as reimbursable'), 'error') - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) - + flash(_("This expense is not marked as reimbursable"), "error") + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) + try: expense.mark_as_reimbursed() - + if safe_commit(db): - flash(_('Expense marked as reimbursed'), 'success') - log_event('expense_reimbursed', user_id=current_user.id, expense_id=expense_id) - track_event(current_user.id, 'expense.reimbursed', {'expense_id': expense_id}) + flash(_("Expense marked as reimbursed"), "success") + log_event("expense_reimbursed", user_id=current_user.id, expense_id=expense_id) + track_event(current_user.id, "expense.reimbursed", {"expense_id": expense_id}) else: - flash(_('Error marking expense as reimbursed'), 'error') - + flash(_("Error marking expense as reimbursed"), "error") + except Exception as e: current_app.logger.error(f"Error marking expense as reimbursed: {e}") - flash(_('Error marking expense as reimbursed'), 'error') - - return redirect(url_for('expenses.view_expense', expense_id=expense_id)) + flash(_("Error marking expense as reimbursed"), "error") + + return redirect(url_for("expenses.view_expense", expense_id=expense_id)) -@expenses_bp.route('/expenses/export') +@expenses_bp.route("/expenses/export") @login_required def export_expenses(): """Export expenses to CSV""" # Get filter parameters (same as list_expenses) - status = request.args.get('status', '').strip() - category = request.args.get('category', '').strip() - project_id = request.args.get('project_id', type=int) - client_id = request.args.get('client_id', type=int) - user_id = request.args.get('user_id', type=int) - start_date = request.args.get('start_date', '').strip() - end_date = request.args.get('end_date', '').strip() - + status = request.args.get("status", "").strip() + category = request.args.get("category", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + user_id = request.args.get("user_id", type=int) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + # Build query query = Expense.query - + # Non-admin users can only export their own expenses if not current_user.is_admin: query = query.filter(Expense.user_id == current_user.id) - + # Apply filters if status: query = query.filter(Expense.status == status) @@ -788,149 +789,159 @@ def export_expenses(): query = query.filter(Expense.user_id == user_id) if start_date: try: - start = datetime.strptime(start_date, '%Y-%m-%d').date() + start = datetime.strptime(start_date, "%Y-%m-%d").date() query = query.filter(Expense.expense_date >= start) except ValueError: pass if end_date: try: - end = datetime.strptime(end_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() query = query.filter(Expense.expense_date <= end) except ValueError: pass - + expenses = query.order_by(Expense.expense_date.desc()).all() - + # Create CSV output = io.StringIO() writer = csv.writer(output) - + # Write header - writer.writerow([ - 'Date', 'Title', 'Category', 'Amount', 'Tax', 'Total', 'Currency', - 'Status', 'Vendor', 'Payment Method', 'Project', 'Client', 'User', - 'Billable', 'Reimbursable', 'Invoiced', 'Receipt Number', 'Notes' - ]) - + writer.writerow( + [ + "Date", + "Title", + "Category", + "Amount", + "Tax", + "Total", + "Currency", + "Status", + "Vendor", + "Payment Method", + "Project", + "Client", + "User", + "Billable", + "Reimbursable", + "Invoiced", + "Receipt Number", + "Notes", + ] + ) + # Write data for expense in expenses: - writer.writerow([ - expense.expense_date.isoformat() if expense.expense_date else '', - expense.title, - expense.category, - float(expense.amount), - float(expense.tax_amount) if expense.tax_amount else 0, - float(expense.total_amount), - expense.currency_code, - expense.status, - expense.vendor or '', - expense.payment_method or '', - expense.project.name if expense.project else '', - expense.client.name if expense.client else '', - expense.user.username if expense.user else '', - 'Yes' if expense.billable else 'No', - 'Yes' if expense.reimbursable else 'No', - 'Yes' if expense.invoiced else 'No', - expense.receipt_number or '', - expense.notes or '' - ]) - + writer.writerow( + [ + expense.expense_date.isoformat() if expense.expense_date else "", + expense.title, + expense.category, + float(expense.amount), + float(expense.tax_amount) if expense.tax_amount else 0, + float(expense.total_amount), + expense.currency_code, + expense.status, + expense.vendor or "", + expense.payment_method or "", + expense.project.name if expense.project else "", + expense.client.name if expense.client else "", + expense.user.username if expense.user else "", + "Yes" if expense.billable else "No", + "Yes" if expense.reimbursable else "No", + "Yes" if expense.invoiced else "No", + expense.receipt_number or "", + expense.notes or "", + ] + ) + # Prepare response output.seek(0) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'expenses_{timestamp}.csv' - + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"expenses_{timestamp}.csv" + # Track export - log_event('expenses_exported', user_id=current_user.id, count=len(expenses)) - track_event(current_user.id, 'expenses.exported', {'count': len(expenses)}) - + log_event("expenses_exported", user_id=current_user.id, count=len(expenses)) + track_event(current_user.id, "expenses.exported", {"count": len(expenses)}) + return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv', - as_attachment=True, - download_name=filename + io.BytesIO(output.getvalue().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name=filename ) -@expenses_bp.route('/expenses/dashboard') +@expenses_bp.route("/expenses/dashboard") @login_required def dashboard(): """Expense dashboard with analytics""" # Track page view from app import track_page_view + track_page_view("expenses_dashboard") - + # Date range - default to current month today = date.today() start_date = date(today.year, today.month, 1) end_date = today - + # Get date range from query params if provided - start_date_str = request.args.get('start_date', '').strip() - end_date_str = request.args.get('end_date', '').strip() - + start_date_str = request.args.get("start_date", "").strip() + end_date_str = request.args.get("end_date", "").strip() + if start_date_str: try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() except ValueError: pass - + if end_date_str: try: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() except ValueError: pass - + # Build base query if current_user.is_admin: query = Expense.query else: query = Expense.query.filter_by(user_id=current_user.id) - + # Apply date filter - query = query.filter( - Expense.expense_date >= start_date, - Expense.expense_date <= end_date - ) - + query = query.filter(Expense.expense_date >= start_date, Expense.expense_date <= end_date) + # Get statistics total_expenses = query.count() - + # Total amount - total_amount_query = db.session.query( - db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0)) - ).filter(Expense.expense_date >= start_date, Expense.expense_date <= end_date) - + total_amount_query = db.session.query(db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))).filter( + Expense.expense_date >= start_date, Expense.expense_date <= end_date + ) + if not current_user.is_admin: total_amount_query = total_amount_query.filter(Expense.user_id == current_user.id) - + total_amount = total_amount_query.scalar() or 0 - + # By status - pending_count = query.filter_by(status='pending').count() - approved_count = query.filter_by(status='approved').count() - rejected_count = query.filter_by(status='rejected').count() - reimbursed_count = query.filter_by(status='reimbursed').count() - + pending_count = query.filter_by(status="pending").count() + approved_count = query.filter_by(status="approved").count() + rejected_count = query.filter_by(status="rejected").count() + reimbursed_count = query.filter_by(status="reimbursed").count() + # Pending reimbursement pending_reimbursement = query.filter( - Expense.status == 'approved', - Expense.reimbursable == True, - Expense.reimbursed == False + Expense.status == "approved", Expense.reimbursable == True, Expense.reimbursed == False ).count() - + # By category category_stats = Expense.get_expenses_by_category( - user_id=None if current_user.is_admin else current_user.id, - start_date=start_date, - end_date=end_date + user_id=None if current_user.is_admin else current_user.id, start_date=start_date, end_date=end_date ) - + # Recent expenses recent_expenses = query.order_by(Expense.expense_date.desc()).limit(10).all() - + return render_template( - 'expenses/dashboard.html', + "expenses/dashboard.html", total_expenses=total_expenses, total_amount=float(total_amount), pending_count=pending_count, @@ -941,28 +952,28 @@ def dashboard(): category_stats=category_stats, recent_expenses=recent_expenses, start_date=start_date.isoformat(), - end_date=end_date.isoformat() + end_date=end_date.isoformat(), ) # API endpoints -@expenses_bp.route('/api/expenses', methods=['GET']) +@expenses_bp.route("/api/expenses", methods=["GET"]) @login_required def api_list_expenses(): """API endpoint to list expenses""" # Similar filters as list_expenses - status = request.args.get('status', '').strip() - category = request.args.get('category', '').strip() - project_id = request.args.get('project_id', type=int) - start_date = request.args.get('start_date', '').strip() - end_date = request.args.get('end_date', '').strip() - + status = request.args.get("status", "").strip() + category = request.args.get("category", "").strip() + project_id = request.args.get("project_id", type=int) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + # Build query query = Expense.query - + if not current_user.is_admin: query = query.filter_by(user_id=current_user.id) - + if status: query = query.filter(Expense.status == status) if category: @@ -971,248 +982,244 @@ def api_list_expenses(): query = query.filter(Expense.project_id == project_id) if start_date: try: - start = datetime.strptime(start_date, '%Y-%m-%d').date() + start = datetime.strptime(start_date, "%Y-%m-%d").date() query = query.filter(Expense.expense_date >= start) except ValueError: pass if end_date: try: - end = datetime.strptime(end_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() query = query.filter(Expense.expense_date <= end) except ValueError: pass - + expenses = query.order_by(Expense.expense_date.desc()).all() - - return jsonify({ - 'expenses': [expense.to_dict() for expense in expenses], - 'count': len(expenses) - }) + + return jsonify({"expenses": [expense.to_dict() for expense in expenses], "count": len(expenses)}) -@expenses_bp.route('/api/expenses/', methods=['GET']) +@expenses_bp.route("/api/expenses/", methods=["GET"]) @login_required def api_get_expense(expense_id): """API endpoint to get a single expense""" expense = Expense.query.get_or_404(expense_id) - + # Check permission if not current_user.is_admin and expense.user_id != current_user.id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + return jsonify(expense.to_dict()) -@expenses_bp.route('/api/expenses/scan-receipt', methods=['POST']) +@expenses_bp.route("/api/expenses/scan-receipt", methods=["POST"]) @login_required def api_scan_receipt(): """API endpoint to scan a receipt image using OCR""" if not is_ocr_available(): - return jsonify({ - 'error': 'OCR not available', - 'message': 'Please install Tesseract OCR and pytesseract' - }), 503 - + return jsonify({"error": "OCR not available", "message": "Please install Tesseract OCR and pytesseract"}), 503 + # Check if file is in request - if 'receipt_file' not in request.files: - return jsonify({'error': 'No file provided'}), 400 - - file = request.files['receipt_file'] - + if "receipt_file" not in request.files: + return jsonify({"error": "No file provided"}), 400 + + file = request.files["receipt_file"] + if not file or not file.filename: - return jsonify({'error': 'No file selected'}), 400 - + return jsonify({"error": "No file selected"}), 400 + if not allowed_file(file.filename): - return jsonify({'error': 'Invalid file type'}), 400 - + return jsonify({"error": "Invalid file type"}), 400 + try: # Save file temporarily filename = secure_filename(file.filename) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"temp_{timestamp}_{filename}" - - temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp') + + temp_dir = os.path.join(current_app.root_path, "..", "uploads", "temp") os.makedirs(temp_dir, exist_ok=True) - + temp_path = os.path.join(temp_dir, filename) file.save(temp_path) - + # Scan receipt - ocr_lang = request.form.get('lang', 'eng') + ocr_lang = request.form.get("lang", "eng") receipt_data = scan_receipt(temp_path, lang=ocr_lang) - + # Get suggested expense data suggestions = get_suggested_expense_data(receipt_data) - + # Clean up temp file try: os.remove(temp_path) except Exception: pass - + # Log event - log_event('receipt_scanned', user_id=current_user.id) - track_event(current_user.id, 'receipt.scanned', { - 'has_amount': bool(receipt_data.get('total')), - 'has_vendor': bool(receipt_data.get('vendor')), - 'has_date': bool(receipt_data.get('date')) - }) - - return jsonify({ - 'success': True, - 'receipt_data': receipt_data, - 'suggestions': suggestions - }) - + log_event("receipt_scanned", user_id=current_user.id) + track_event( + current_user.id, + "receipt.scanned", + { + "has_amount": bool(receipt_data.get("total")), + "has_vendor": bool(receipt_data.get("vendor")), + "has_date": bool(receipt_data.get("date")), + }, + ) + + return jsonify({"success": True, "receipt_data": receipt_data, "suggestions": suggestions}) + except Exception as e: current_app.logger.error(f"Error scanning receipt: {e}") - return jsonify({ - 'error': 'Failed to scan receipt', - 'message': str(e) - }), 500 + return jsonify({"error": "Failed to scan receipt", "message": str(e)}), 500 -@expenses_bp.route('/expenses/scan-receipt', methods=['GET', 'POST']) +@expenses_bp.route("/expenses/scan-receipt", methods=["GET", "POST"]) @login_required def scan_receipt_page(): """Page for scanning receipts with OCR""" - if request.method == 'GET': - return render_template('expenses/scan_receipt.html', ocr_available=is_ocr_available()) - + if request.method == "GET": + return render_template("expenses/scan_receipt.html", ocr_available=is_ocr_available()) + # POST - handle receipt scanning if not is_ocr_available(): - flash(_('OCR is not available. Please contact your administrator.'), 'error') - return redirect(url_for('expenses.scan_receipt_page')) - - if 'receipt_file' not in request.files: - flash(_('No file provided'), 'error') - return redirect(url_for('expenses.scan_receipt_page')) - - file = request.files['receipt_file'] - + flash(_("OCR is not available. Please contact your administrator."), "error") + return redirect(url_for("expenses.scan_receipt_page")) + + if "receipt_file" not in request.files: + flash(_("No file provided"), "error") + return redirect(url_for("expenses.scan_receipt_page")) + + file = request.files["receipt_file"] + if not file or not file.filename: - flash(_('No file selected'), 'error') - return redirect(url_for('expenses.scan_receipt_page')) - + flash(_("No file selected"), "error") + return redirect(url_for("expenses.scan_receipt_page")) + if not allowed_file(file.filename): - flash(_('Invalid file type. Allowed types: png, jpg, jpeg, gif, pdf'), 'error') - return redirect(url_for('expenses.scan_receipt_page')) - + flash(_("Invalid file type. Allowed types: png, jpg, jpeg, gif, pdf"), "error") + return redirect(url_for("expenses.scan_receipt_page")) + try: # Save file temporarily filename = secure_filename(file.filename) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") temp_filename = f"temp_{timestamp}_{filename}" - - temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp') + + temp_dir = os.path.join(current_app.root_path, "..", "uploads", "temp") os.makedirs(temp_dir, exist_ok=True) - + temp_path = os.path.join(temp_dir, temp_filename) file.save(temp_path) - + # Scan receipt - ocr_lang = request.form.get('lang', 'eng') + ocr_lang = request.form.get("lang", "eng") receipt_data = scan_receipt(temp_path, lang=ocr_lang) - + # Get suggested expense data suggestions = get_suggested_expense_data(receipt_data) - + # Save receipt permanently filename = f"{timestamp}_{filename}" - upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER) + upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER) os.makedirs(upload_dir, exist_ok=True) - + permanent_path = os.path.join(upload_dir, filename) os.rename(temp_path, permanent_path) - + receipt_path = os.path.join(UPLOAD_FOLDER, filename) - + # Store OCR data in session for use in expense creation from flask import session - session['scanned_receipt'] = { - 'receipt_path': receipt_path, - 'receipt_data': receipt_data, - 'suggestions': suggestions + + session["scanned_receipt"] = { + "receipt_path": receipt_path, + "receipt_data": receipt_data, + "suggestions": suggestions, } - + # Log event - log_event('receipt_scanned', user_id=current_user.id) - track_event(current_user.id, 'receipt.scanned', { - 'has_amount': bool(receipt_data.get('total')), - 'has_vendor': bool(receipt_data.get('vendor')), - 'has_date': bool(receipt_data.get('date')) - }) - - flash(_('Receipt scanned successfully! You can now create an expense with the extracted data.'), 'success') - return redirect(url_for('expenses.create_expense_from_scan')) - + log_event("receipt_scanned", user_id=current_user.id) + track_event( + current_user.id, + "receipt.scanned", + { + "has_amount": bool(receipt_data.get("total")), + "has_vendor": bool(receipt_data.get("vendor")), + "has_date": bool(receipt_data.get("date")), + }, + ) + + flash(_("Receipt scanned successfully! You can now create an expense with the extracted data."), "success") + return redirect(url_for("expenses.create_expense_from_scan")) + except Exception as e: current_app.logger.error(f"Error scanning receipt: {e}") - flash(_('Error scanning receipt. Please try again or enter the expense manually.'), 'error') - return redirect(url_for('expenses.scan_receipt_page')) + flash(_("Error scanning receipt. Please try again or enter the expense manually."), "error") + return redirect(url_for("expenses.scan_receipt_page")) -@expenses_bp.route('/expenses/create-from-scan', methods=['GET', 'POST']) +@expenses_bp.route("/expenses/create-from-scan", methods=["GET", "POST"]) @login_required def create_expense_from_scan(): """Create expense from scanned receipt data""" from flask import session - - scanned_data = session.get('scanned_receipt') - + + scanned_data = session.get("scanned_receipt") + if not scanned_data: - flash(_('No scanned receipt data found. Please scan a receipt first.'), 'error') - return redirect(url_for('expenses.scan_receipt_page')) - - if request.method == 'GET': + flash(_("No scanned receipt data found. Please scan a receipt first."), "error") + return redirect(url_for("expenses.scan_receipt_page")) + + if request.method == "GET": # Get data for form - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() categories = Expense.get_expense_categories() payment_methods = Expense.get_payment_methods() - + return render_template( - 'expenses/create_from_scan.html', + "expenses/create_from_scan.html", expense=None, projects=projects, clients=clients, categories=categories, payment_methods=payment_methods, - suggestions=scanned_data.get('suggestions', {}), - receipt_data=scanned_data.get('receipt_data', {}) + suggestions=scanned_data.get("suggestions", {}), + receipt_data=scanned_data.get("receipt_data", {}), ) - + # POST - create the expense try: # Get form data (similar to create_expense) - title = request.form.get('title', '').strip() - description = request.form.get('description', '').strip() - category = request.form.get('category', '').strip() - amount = request.form.get('amount', '0').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - tax_amount = request.form.get('tax_amount', '0').strip() - expense_date = request.form.get('expense_date', '').strip() - + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + amount = request.form.get("amount", "0").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + tax_amount = request.form.get("tax_amount", "0").strip() + expense_date = request.form.get("expense_date", "").strip() + # Validate required fields if not all([title, category, amount, expense_date]): - flash(_('Please fill in all required fields'), 'error') - return redirect(url_for('expenses.create_expense_from_scan')) - + flash(_("Please fill in all required fields"), "error") + return redirect(url_for("expenses.create_expense_from_scan")) + # Parse date try: - expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date() + expense_date_obj = datetime.strptime(expense_date, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('expenses.create_expense_from_scan')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("expenses.create_expense_from_scan")) + # Parse amounts try: amount_decimal = Decimal(amount) - tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0') + tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal("0") except (ValueError, Decimal.InvalidOperation): - flash(_('Invalid amount format'), 'error') - return redirect(url_for('expenses.create_expense_from_scan')) - + flash(_("Invalid amount format"), "error") + return redirect(url_for("expenses.create_expense_from_scan")) + # Create expense with OCR data expense = Expense( user_id=current_user.id, @@ -1223,43 +1230,42 @@ def create_expense_from_scan(): description=description, currency_code=currency_code, tax_amount=tax_amount_decimal, - project_id=request.form.get('project_id', type=int), - client_id=request.form.get('client_id', type=int), - payment_method=request.form.get('payment_method', '').strip(), - vendor=request.form.get('vendor', '').strip(), - receipt_number=request.form.get('receipt_number', '').strip(), - receipt_path=scanned_data.get('receipt_path'), - notes=request.form.get('notes', '').strip(), - tags=request.form.get('tags', '').strip(), - billable=request.form.get('billable') == 'on', - reimbursable=request.form.get('reimbursable') == 'on' + project_id=request.form.get("project_id", type=int), + client_id=request.form.get("client_id", type=int), + payment_method=request.form.get("payment_method", "").strip(), + vendor=request.form.get("vendor", "").strip(), + receipt_number=request.form.get("receipt_number", "").strip(), + receipt_path=scanned_data.get("receipt_path"), + notes=request.form.get("notes", "").strip(), + tags=request.form.get("tags", "").strip(), + billable=request.form.get("billable") == "on", + reimbursable=request.form.get("reimbursable") == "on", ) - + # Store OCR data as JSON - if scanned_data.get('receipt_data'): + if scanned_data.get("receipt_data"): # expense.ocr_data = json.dumps(scanned_data['receipt_data']) # Uncomment after migration pass - + db.session.add(expense) - + if safe_commit(db): # Clear scanned data from session - session.pop('scanned_receipt', None) - - flash(_('Expense created successfully from scanned receipt'), 'success') - log_event('expense_created_from_scan', user_id=current_user.id, expense_id=expense.id) - track_event(current_user.id, 'expense.created_from_scan', { - 'expense_id': expense.id, - 'category': category, - 'amount': float(amount_decimal) - }) - return redirect(url_for('expenses.view_expense', expense_id=expense.id)) + session.pop("scanned_receipt", None) + + flash(_("Expense created successfully from scanned receipt"), "success") + log_event("expense_created_from_scan", user_id=current_user.id, expense_id=expense.id) + track_event( + current_user.id, + "expense.created_from_scan", + {"expense_id": expense.id, "category": category, "amount": float(amount_decimal)}, + ) + return redirect(url_for("expenses.view_expense", expense_id=expense.id)) else: - flash(_('Error creating expense'), 'error') - return redirect(url_for('expenses.create_expense_from_scan')) - + flash(_("Error creating expense"), "error") + return redirect(url_for("expenses.create_expense_from_scan")) + except Exception as e: current_app.logger.error(f"Error creating expense from scan: {e}") - flash(_('Error creating expense'), 'error') - return redirect(url_for('expenses.create_expense_from_scan')) - + flash(_("Error creating expense"), "error") + return redirect(url_for("expenses.create_expense_from_scan")) diff --git a/app/routes/gantt.py b/app/routes/gantt.py index 8b53701c..13d2e962 100644 --- a/app/routes/gantt.py +++ b/app/routes/gantt.py @@ -10,70 +10,63 @@ from datetime import datetime, timedelta from sqlalchemy import func -gantt_bp = Blueprint('gantt', __name__) +gantt_bp = Blueprint("gantt", __name__) -@gantt_bp.route('/gantt') +@gantt_bp.route("/gantt") @login_required def gantt_view(): """Main Gantt chart view.""" - project_id = request.args.get('project_id', type=int) - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - - return render_template( - 'gantt/view.html', - projects=projects, - selected_project_id=project_id - ) + project_id = request.args.get("project_id", type=int) + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + + return render_template("gantt/view.html", projects=projects, selected_project_id=project_id) -@gantt_bp.route('/api/gantt/data') +@gantt_bp.route("/api/gantt/data") @login_required def gantt_data(): """Get Gantt chart data as JSON.""" - project_id = request.args.get('project_id', type=int) - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') - + project_id = request.args.get("project_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + # Parse dates if start_date: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') + start_dt = datetime.strptime(start_date, "%Y-%m-%d") else: start_dt = datetime.utcnow() - timedelta(days=90) - + if end_date: - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + end_dt = datetime.strptime(end_date, "%Y-%m-%d") else: end_dt = datetime.utcnow() + timedelta(days=90) - + # Get projects - query = Project.query.filter_by(status='active') + query = Project.query.filter_by(status="active") if project_id: query = query.filter_by(id=project_id) - + if not current_user.is_admin: # Filter by user's projects or projects they have time entries for query = query.filter( db.or_( Project.created_by == current_user.id, Project.id.in_( - db.session.query(TimeEntry.project_id) - .filter_by(user_id=current_user.id) - .distinct() - .subquery() - ) + db.session.query(TimeEntry.project_id).filter_by(user_id=current_user.id).distinct().subquery() + ), ) ) - + projects = query.all() - + # Build Gantt data gantt_data = [] - + for project in projects: # Get project start and end dates from tasks tasks = Task.query.filter_by(project_id=project.id).all() - + if not tasks: # If no tasks, use project creation date project_start = project.created_at or datetime.utcnow() @@ -86,32 +79,34 @@ def gantt_data(): task_dates.append(datetime.combine(task.due_date, datetime.min.time())) if task.created_at: task_dates.append(task.created_at) - + if task_dates: project_start = min(task_dates) project_end = max(task_dates) + timedelta(days=7) # Add buffer else: project_start = project.created_at or datetime.utcnow() project_end = project_start + timedelta(days=30) - + # Ensure dates are within requested range if project_start < start_dt: project_start = start_dt if project_end > end_dt: project_end = end_dt - + # Add project as parent task - gantt_data.append({ - 'id': f'project-{project.id}', - 'name': project.name, - 'start': project_start.strftime('%Y-%m-%d'), - 'end': project_end.strftime('%Y-%m-%d'), - 'progress': calculate_project_progress(project), - 'type': 'project', - 'project_id': project.id, - 'dependencies': [] - }) - + gantt_data.append( + { + "id": f"project-{project.id}", + "name": project.name, + "start": project_start.strftime("%Y-%m-%d"), + "end": project_end.strftime("%Y-%m-%d"), + "progress": calculate_project_progress(project), + "type": "project", + "project_id": project.id, + "dependencies": [], + } + ) + # Add tasks as child items for task in tasks: # Use due_date if available, otherwise estimate from created_at @@ -121,35 +116,35 @@ def gantt_data(): else: task_start = task.created_at or project_start task_end = task_start + timedelta(days=7) - + # Ensure dates are within range if task_start < start_dt: task_start = start_dt if task_end > end_dt: task_end = end_dt - + dependencies = [] # Task dependencies would need to be added to Task model if needed - - gantt_data.append({ - 'id': f'task-{task.id}', - 'name': task.name, - 'start': task_start.strftime('%Y-%m-%d'), - 'end': task_end.strftime('%Y-%m-%d'), - 'progress': calculate_task_progress(task), - 'type': 'task', - 'task_id': task.id, - 'project_id': project.id, - 'parent': f'project-{project.id}', - 'dependencies': dependencies, - 'status': task.status - }) - - return jsonify({ - 'data': gantt_data, - 'start_date': start_dt.strftime('%Y-%m-%d'), - 'end_date': end_dt.strftime('%Y-%m-%d') - }) + + gantt_data.append( + { + "id": f"task-{task.id}", + "name": task.name, + "start": task_start.strftime("%Y-%m-%d"), + "end": task_end.strftime("%Y-%m-%d"), + "progress": calculate_task_progress(task), + "type": "task", + "task_id": task.id, + "project_id": project.id, + "parent": f"project-{project.id}", + "dependencies": dependencies, + "status": task.status, + } + ) + + return jsonify( + {"data": gantt_data, "start_date": start_dt.strftime("%Y-%m-%d"), "end_date": end_dt.strftime("%Y-%m-%d")} + ) def calculate_project_progress(project): @@ -157,19 +152,18 @@ def calculate_project_progress(project): tasks = Task.query.filter_by(project_id=project.id).all() if not tasks: return 0 - - completed = sum(1 for t in tasks if t.status == 'done') + + completed = sum(1 for t in tasks if t.status == "done") return int((completed / len(tasks)) * 100) def calculate_task_progress(task): """Calculate task progress percentage.""" - if task.status == 'done': + if task.status == "done": return 100 - elif task.status == 'in_progress': + elif task.status == "in_progress": return 50 - elif task.status == 'review': + elif task.status == "review": return 75 else: return 0 - diff --git a/app/routes/import_export.py b/app/routes/import_export.py index 66cbdfc9..620abc27 100644 --- a/app/routes/import_export.py +++ b/app/routes/import_export.py @@ -1,6 +1,7 @@ """ Import/Export routes for data migration and GDPR compliance """ + from flask import Blueprint, jsonify, request, send_file, current_app, render_template from flask_login import login_required, current_user from werkzeug.utils import secure_filename @@ -11,89 +12,76 @@ import_from_toggl, import_from_harvest, restore_from_backup, - ImportError as DataImportError -) -from app.utils.data_export import ( - export_user_data_gdpr, - export_filtered_data, - create_backup + ImportError as DataImportError, ) +from app.utils.data_export import export_user_data_gdpr, export_filtered_data, create_backup from datetime import datetime, timedelta import os import json -import_export_bp = Blueprint('import_export', __name__) +import_export_bp = Blueprint("import_export", __name__) # ============================================================================ # Import Routes # ============================================================================ -@import_export_bp.route('/import-export') + +@import_export_bp.route("/import-export") @login_required def import_export_page(): """Render the import/export page""" - return render_template('import_export/index.html') + return render_template("import_export/index.html") -@import_export_bp.route('/api/import/csv', methods=['POST']) +@import_export_bp.route("/api/import/csv", methods=["POST"]) @login_required def import_csv(): """ Import time entries from CSV file - + Expected multipart/form-data with 'file' field """ - if 'file' not in request.files: - return jsonify({'error': 'No file provided'}), 400 - - file = request.files['file'] - - if file.filename == '': - return jsonify({'error': 'No file selected'}), 400 - - if not file.filename.endswith('.csv'): - return jsonify({'error': 'File must be a CSV'}), 400 - + if "file" not in request.files: + return jsonify({"error": "No file provided"}), 400 + + file = request.files["file"] + + if file.filename == "": + return jsonify({"error": "No file selected"}), 400 + + if not file.filename.endswith(".csv"): + return jsonify({"error": "File must be a CSV"}), 400 + try: # Read file content - csv_content = file.read().decode('utf-8') - + csv_content = file.read().decode("utf-8") + # Create import record import_record = DataImport( - user_id=current_user.id, - import_type='csv', - source_file=secure_filename(file.filename) + user_id=current_user.id, import_type="csv", source_file=secure_filename(file.filename) ) db.session.add(import_record) db.session.commit() - + # Perform import - summary = import_csv_time_entries( - user_id=current_user.id, - csv_content=csv_content, - import_record=import_record - ) - - return jsonify({ - 'success': True, - 'import_id': import_record.id, - 'summary': summary - }), 200 - + summary = import_csv_time_entries(user_id=current_user.id, csv_content=csv_content, import_record=import_record) + + return jsonify({"success": True, "import_id": import_record.id, "summary": summary}), 200 + except DataImportError as e: - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 except Exception as e: current_app.logger.error(f"CSV import error: {str(e)}") - return jsonify({'error': 'Import failed. Please check the file format.'}), 500 + return jsonify({"error": "Import failed. Please check the file format."}), 500 -@import_export_bp.route('/api/import/toggl', methods=['POST']) +@import_export_bp.route("/api/import/toggl", methods=["POST"]) @login_required def import_toggl(): """ Import time entries from Toggl Track - + Expected JSON body: { "api_token": "...", @@ -103,32 +91,30 @@ def import_toggl(): } """ data = request.get_json() - + if not data: - return jsonify({'error': 'No data provided'}), 400 - - api_token = data.get('api_token') - workspace_id = data.get('workspace_id') - start_date_str = data.get('start_date') - end_date_str = data.get('end_date') - + return jsonify({"error": "No data provided"}), 400 + + api_token = data.get("api_token") + workspace_id = data.get("workspace_id") + start_date_str = data.get("start_date") + end_date_str = data.get("end_date") + if not all([api_token, workspace_id, start_date_str, end_date_str]): - return jsonify({'error': 'Missing required fields'}), 400 - + return jsonify({"error": "Missing required fields"}), 400 + try: # Parse dates - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - end_date = datetime.strptime(end_date_str, '%Y-%m-%d') - + start_date = datetime.strptime(start_date_str, "%Y-%m-%d") + end_date = datetime.strptime(end_date_str, "%Y-%m-%d") + # Create import record import_record = DataImport( - user_id=current_user.id, - import_type='toggl', - source_file=f'Toggl Workspace {workspace_id}' + user_id=current_user.id, import_type="toggl", source_file=f"Toggl Workspace {workspace_id}" ) db.session.add(import_record) db.session.commit() - + # Perform import summary = import_from_toggl( user_id=current_user.id, @@ -136,28 +122,24 @@ def import_toggl(): workspace_id=workspace_id, start_date=start_date, end_date=end_date, - import_record=import_record + import_record=import_record, ) - - return jsonify({ - 'success': True, - 'import_id': import_record.id, - 'summary': summary - }), 200 - + + return jsonify({"success": True, "import_id": import_record.id, "summary": summary}), 200 + except DataImportError as e: - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 except Exception as e: current_app.logger.error(f"Toggl import error: {str(e)}") - return jsonify({'error': 'Import failed. Please check your credentials and try again.'}), 500 + return jsonify({"error": "Import failed. Please check your credentials and try again."}), 500 -@import_export_bp.route('/api/import/harvest', methods=['POST']) +@import_export_bp.route("/api/import/harvest", methods=["POST"]) @login_required def import_harvest(): """ Import time entries from Harvest - + Expected JSON body: { "account_id": "...", @@ -167,32 +149,30 @@ def import_harvest(): } """ data = request.get_json() - + if not data: - return jsonify({'error': 'No data provided'}), 400 - - account_id = data.get('account_id') - api_token = data.get('api_token') - start_date_str = data.get('start_date') - end_date_str = data.get('end_date') - + return jsonify({"error": "No data provided"}), 400 + + account_id = data.get("account_id") + api_token = data.get("api_token") + start_date_str = data.get("start_date") + end_date_str = data.get("end_date") + if not all([account_id, api_token, start_date_str, end_date_str]): - return jsonify({'error': 'Missing required fields'}), 400 - + return jsonify({"error": "Missing required fields"}), 400 + try: # Parse dates - start_date = datetime.strptime(start_date_str, '%Y-%m-%d') - end_date = datetime.strptime(end_date_str, '%Y-%m-%d') - + start_date = datetime.strptime(start_date_str, "%Y-%m-%d") + end_date = datetime.strptime(end_date_str, "%Y-%m-%d") + # Create import record import_record = DataImport( - user_id=current_user.id, - import_type='harvest', - source_file=f'Harvest Account {account_id}' + user_id=current_user.id, import_type="harvest", source_file=f"Harvest Account {account_id}" ) db.session.add(import_record) db.session.commit() - + # Perform import summary = import_from_harvest( user_id=current_user.id, @@ -200,116 +180,107 @@ def import_harvest(): api_token=api_token, start_date=start_date, end_date=end_date, - import_record=import_record + import_record=import_record, ) - - return jsonify({ - 'success': True, - 'import_id': import_record.id, - 'summary': summary - }), 200 - + + return jsonify({"success": True, "import_id": import_record.id, "summary": summary}), 200 + except DataImportError as e: - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 except Exception as e: current_app.logger.error(f"Harvest import error: {str(e)}") - return jsonify({'error': 'Import failed. Please check your credentials and try again.'}), 500 + return jsonify({"error": "Import failed. Please check your credentials and try again."}), 500 -@import_export_bp.route('/api/import/status/') +@import_export_bp.route("/api/import/status/") @login_required def import_status(import_id): """Get status of an import operation""" import_record = DataImport.query.get_or_404(import_id) - + # Check permissions if not current_user.is_admin and import_record.user_id != current_user.id: - return jsonify({'error': 'Unauthorized'}), 403 - + return jsonify({"error": "Unauthorized"}), 403 + return jsonify(import_record.to_dict()), 200 -@import_export_bp.route('/api/import/history') +@import_export_bp.route("/api/import/history") @login_required def import_history(): """Get import history for current user""" if current_user.is_admin: imports = DataImport.query.order_by(DataImport.started_at.desc()).limit(50).all() else: - imports = DataImport.query.filter_by(user_id=current_user.id).order_by( - DataImport.started_at.desc() - ).limit(50).all() - - return jsonify({ - 'imports': [imp.to_dict() for imp in imports] - }), 200 + imports = ( + DataImport.query.filter_by(user_id=current_user.id).order_by(DataImport.started_at.desc()).limit(50).all() + ) + + return jsonify({"imports": [imp.to_dict() for imp in imports]}), 200 # ============================================================================ # Export Routes # ============================================================================ -@import_export_bp.route('/api/export/gdpr', methods=['POST']) + +@import_export_bp.route("/api/export/gdpr", methods=["POST"]) @login_required def export_gdpr(): """ Export all user data for GDPR compliance - + Expected JSON body: { "format": "json" | "zip" } """ data = request.get_json() or {} - export_format = data.get('format', 'json') - - if export_format not in ['json', 'zip']: - return jsonify({'error': 'Invalid format. Use "json" or "zip"'}), 400 - + export_format = data.get("format", "json") + + if export_format not in ["json", "zip"]: + return jsonify({"error": 'Invalid format. Use "json" or "zip"'}), 400 + try: # Create export record - export_record = DataExport( - user_id=current_user.id, - export_type='gdpr', - export_format=export_format - ) + export_record = DataExport(user_id=current_user.id, export_type="gdpr", export_format=export_format) db.session.add(export_record) db.session.commit() - + export_record.start_processing() - + # Perform export - result = export_user_data_gdpr( - user_id=current_user.id, - export_format=export_format - ) - + result = export_user_data_gdpr(user_id=current_user.id, export_format=export_format) + export_record.complete( - file_path=result['filepath'], - file_size=result['file_size'], - record_count=result['record_count'] + file_path=result["filepath"], file_size=result["file_size"], record_count=result["record_count"] + ) + + return ( + jsonify( + { + "success": True, + "export_id": export_record.id, + "filename": result["filename"], + "download_url": f"/api/export/download/{export_record.id}", + } + ), + 200, ) - - return jsonify({ - 'success': True, - 'export_id': export_record.id, - 'filename': result['filename'], - 'download_url': f'/api/export/download/{export_record.id}' - }), 200 - + except Exception as e: current_app.logger.error(f"GDPR export error: {str(e)}") - if 'export_record' in locals(): + if "export_record" in locals(): export_record.fail(str(e)) - return jsonify({'error': 'Export failed. Please try again.'}), 500 + return jsonify({"error": "Export failed. Please try again."}), 500 -@import_export_bp.route('/api/export/filtered', methods=['POST']) +@import_export_bp.route("/api/export/filtered", methods=["POST"]) @login_required def export_filtered(): """ Export filtered data - + Expected JSON body: { "format": "json" | "csv", @@ -325,235 +296,226 @@ def export_filtered(): } """ data = request.get_json() - + if not data: - return jsonify({'error': 'No data provided'}), 400 - - export_format = data.get('format', 'json') - filters = data.get('filters', {}) - - if export_format not in ['json', 'csv']: - return jsonify({'error': 'Invalid format. Use "json" or "csv"'}), 400 - + return jsonify({"error": "No data provided"}), 400 + + export_format = data.get("format", "json") + filters = data.get("filters", {}) + + if export_format not in ["json", "csv"]: + return jsonify({"error": 'Invalid format. Use "json" or "csv"'}), 400 + try: # Create export record export_record = DataExport( - user_id=current_user.id, - export_type='filtered', - export_format=export_format, - filters=filters + user_id=current_user.id, export_type="filtered", export_format=export_format, filters=filters ) db.session.add(export_record) db.session.commit() - + export_record.start_processing() - + # Perform export - result = export_filtered_data( - user_id=current_user.id, - filters=filters, - export_format=export_format - ) - + result = export_filtered_data(user_id=current_user.id, filters=filters, export_format=export_format) + export_record.complete( - file_path=result['filepath'], - file_size=result['file_size'], - record_count=result['record_count'] + file_path=result["filepath"], file_size=result["file_size"], record_count=result["record_count"] + ) + + return ( + jsonify( + { + "success": True, + "export_id": export_record.id, + "filename": result["filename"], + "download_url": f"/api/export/download/{export_record.id}", + } + ), + 200, ) - - return jsonify({ - 'success': True, - 'export_id': export_record.id, - 'filename': result['filename'], - 'download_url': f'/api/export/download/{export_record.id}' - }), 200 - + except Exception as e: current_app.logger.error(f"Filtered export error: {str(e)}") - if 'export_record' in locals(): + if "export_record" in locals(): export_record.fail(str(e)) - return jsonify({'error': 'Export failed. Please try again.'}), 500 + return jsonify({"error": "Export failed. Please try again."}), 500 -@import_export_bp.route('/api/export/backup', methods=['POST']) +@import_export_bp.route("/api/export/backup", methods=["POST"]) @login_required def export_backup(): """ Create a full database backup (admin only) """ if not current_user.is_admin: - return jsonify({'error': 'Admin access required'}), 403 - + return jsonify({"error": "Admin access required"}), 403 + try: # Create export record - export_record = DataExport( - user_id=current_user.id, - export_type='backup', - export_format='json' - ) + export_record = DataExport(user_id=current_user.id, export_type="backup", export_format="json") db.session.add(export_record) db.session.commit() - + export_record.start_processing() - + # Create backup result = create_backup(user_id=current_user.id) - + export_record.complete( - file_path=result['filepath'], - file_size=result['file_size'], - record_count=result['record_count'] + file_path=result["filepath"], file_size=result["file_size"], record_count=result["record_count"] ) - - return jsonify({ - 'success': True, - 'export_id': export_record.id, - 'filename': result['filename'], - 'download_url': f'/api/export/download/{export_record.id}' - }), 200 - + + return ( + jsonify( + { + "success": True, + "export_id": export_record.id, + "filename": result["filename"], + "download_url": f"/api/export/download/{export_record.id}", + } + ), + 200, + ) + except Exception as e: current_app.logger.error(f"Backup creation error: {str(e)}") - if 'export_record' in locals(): + if "export_record" in locals(): export_record.fail(str(e)) - return jsonify({'error': 'Backup failed. Please try again.'}), 500 + return jsonify({"error": "Backup failed. Please try again."}), 500 -@import_export_bp.route('/api/export/download/') +@import_export_bp.route("/api/export/download/") @login_required def download_export(export_id): """Download an export file""" export_record = DataExport.query.get_or_404(export_id) - + # Check permissions if not current_user.is_admin and export_record.user_id != current_user.id: - return jsonify({'error': 'Unauthorized'}), 403 - + return jsonify({"error": "Unauthorized"}), 403 + # Check if export is complete - if export_record.status != 'completed': - return jsonify({'error': 'Export is not ready yet'}), 400 - + if export_record.status != "completed": + return jsonify({"error": "Export is not ready yet"}), 400 + # Check if file exists if not export_record.file_path or not os.path.exists(export_record.file_path): - return jsonify({'error': 'Export file not found'}), 404 - + return jsonify({"error": "Export file not found"}), 404 + # Check if expired if export_record.is_expired(): - return jsonify({'error': 'Export has expired'}), 410 - + return jsonify({"error": "Export has expired"}), 410 + return send_file( - export_record.file_path, - as_attachment=True, - download_name=os.path.basename(export_record.file_path) + export_record.file_path, as_attachment=True, download_name=os.path.basename(export_record.file_path) ) -@import_export_bp.route('/api/export/status/') +@import_export_bp.route("/api/export/status/") @login_required def export_status(export_id): """Get status of an export operation""" export_record = DataExport.query.get_or_404(export_id) - + # Check permissions if not current_user.is_admin and export_record.user_id != current_user.id: - return jsonify({'error': 'Unauthorized'}), 403 - + return jsonify({"error": "Unauthorized"}), 403 + return jsonify(export_record.to_dict()), 200 -@import_export_bp.route('/api/export/history') +@import_export_bp.route("/api/export/history") @login_required def export_history(): """Get export history for current user""" if current_user.is_admin: exports = DataExport.query.order_by(DataExport.created_at.desc()).limit(50).all() else: - exports = DataExport.query.filter_by(user_id=current_user.id).order_by( - DataExport.created_at.desc() - ).limit(50).all() - - return jsonify({ - 'exports': [exp.to_dict() for exp in exports] - }), 200 + exports = ( + DataExport.query.filter_by(user_id=current_user.id).order_by(DataExport.created_at.desc()).limit(50).all() + ) + + return jsonify({"exports": [exp.to_dict() for exp in exports]}), 200 # ============================================================================ # Backup/Restore Routes # ============================================================================ -@import_export_bp.route('/api/backup/restore', methods=['POST']) + +@import_export_bp.route("/api/backup/restore", methods=["POST"]) @login_required def restore_backup(): """ Restore from backup file (admin only) - + Expected multipart/form-data with 'file' field """ if not current_user.is_admin: - return jsonify({'error': 'Admin access required'}), 403 - - if 'file' not in request.files: - return jsonify({'error': 'No file provided'}), 400 - - file = request.files['file'] - - if file.filename == '': - return jsonify({'error': 'No file selected'}), 400 - - if not file.filename.endswith('.json'): - return jsonify({'error': 'File must be a JSON backup file'}), 400 - + return jsonify({"error": "Admin access required"}), 403 + + if "file" not in request.files: + return jsonify({"error": "No file provided"}), 400 + + file = request.files["file"] + + if file.filename == "": + return jsonify({"error": "No file selected"}), 400 + + if not file.filename.endswith(".json"): + return jsonify({"error": "File must be a JSON backup file"}), 400 + try: # Save uploaded file temporarily - backup_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'backups') + backup_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "backups") os.makedirs(backup_dir, exist_ok=True) - + filename = secure_filename(file.filename) - filepath = os.path.join(backup_dir, f'restore_{filename}') + filepath = os.path.join(backup_dir, f"restore_{filename}") file.save(filepath) - + # Create import record - import_record = DataImport( - user_id=current_user.id, - import_type='backup', - source_file=filename - ) + import_record = DataImport(user_id=current_user.id, import_type="backup", source_file=filename) db.session.add(import_record) db.session.commit() - + # Perform restore - statistics = restore_from_backup( - user_id=current_user.id, - backup_file_path=filepath - ) - + statistics = restore_from_backup(user_id=current_user.id, backup_file_path=filepath) + # Clean up temporary file os.remove(filepath) - - return jsonify({ - 'success': True, - 'import_id': import_record.id, - 'statistics': statistics, - 'message': 'Backup restored successfully' - }), 200 - + + return ( + jsonify( + { + "success": True, + "import_id": import_record.id, + "statistics": statistics, + "message": "Backup restored successfully", + } + ), + 200, + ) + except DataImportError as e: - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 except Exception as e: current_app.logger.error(f"Backup restore error: {str(e)}") - return jsonify({'error': 'Restore failed. Please check the backup file.'}), 500 + return jsonify({"error": "Restore failed. Please check the backup file."}), 500 # ============================================================================ # Migration Wizard Routes # ============================================================================ -@import_export_bp.route('/api/migration/wizard/start', methods=['POST']) + +@import_export_bp.route("/api/migration/wizard/start", methods=["POST"]) @login_required def start_migration_wizard(): """ Start the migration wizard - + Expected JSON body: { "source": "toggl" | "harvest" | "csv", @@ -562,71 +524,66 @@ def start_migration_wizard(): } """ data = request.get_json() - + if not data: - return jsonify({'error': 'No data provided'}), 400 - - source = data.get('source') - - if source not in ['toggl', 'harvest', 'csv']: - return jsonify({'error': 'Invalid source'}), 400 - + return jsonify({"error": "No data provided"}), 400 + + source = data.get("source") + + if source not in ["toggl", "harvest", "csv"]: + return jsonify({"error": "Invalid source"}), 400 + # Store wizard state in session or return wizard ID wizard_id = f"wizard_{current_user.id}_{datetime.utcnow().timestamp()}" - - return jsonify({ - 'success': True, - 'wizard_id': wizard_id, - 'next_step': 'credentials', - 'message': f'Migration wizard started for {source}' - }), 200 + + return ( + jsonify( + { + "success": True, + "wizard_id": wizard_id, + "next_step": "credentials", + "message": f"Migration wizard started for {source}", + } + ), + 200, + ) -@import_export_bp.route('/api/migration/wizard//preview', methods=['POST']) +@import_export_bp.route("/api/migration/wizard//preview", methods=["POST"]) @login_required def preview_migration(wizard_id): """ Preview data before importing - + This would fetch a small sample of data to show the user what will be imported """ data = request.get_json() - + # Implementation would depend on the source # For now, return a mock preview - - return jsonify({ - 'success': True, - 'preview': { - 'sample_entries': [], - 'total_count': 0, - 'date_range': {} - } - }), 200 + + return jsonify({"success": True, "preview": {"sample_entries": [], "total_count": 0, "date_range": {}}}), 200 -@import_export_bp.route('/api/migration/wizard//execute', methods=['POST']) +@import_export_bp.route("/api/migration/wizard//execute", methods=["POST"]) @login_required def execute_migration(wizard_id): """ Execute the migration after preview """ data = request.get_json() - + # This would trigger the actual import based on the wizard configuration - - return jsonify({ - 'success': True, - 'message': 'Migration started', - 'import_id': None - }), 200 + + return jsonify({"success": True, "message": "Migration started", "import_id": None}), 200 # ============================================================================ # Template Endpoints # ============================================================================ -@import_export_bp.route('/api/import/template/csv') + +@import_export_bp.route("/api/import/template/csv") @login_required def download_csv_template(): """Download CSV import template""" @@ -634,17 +591,11 @@ def download_csv_template(): Example Project,Example Client,Example Task,2024-01-01 09:00:00,2024-01-01 10:30:00,1.5,Meeting with client,meeting;client,true Another Project,Another Client,,2024-01-01 14:00:00,2024-01-01 16:00:00,2.0,Development work,dev;coding,true """ - + from io import BytesIO - + buffer = BytesIO() - buffer.write(template_content.encode('utf-8')) + buffer.write(template_content.encode("utf-8")) buffer.seek(0) - - return send_file( - buffer, - mimetype='text/csv', - as_attachment=True, - download_name='timetracker_import_template.csv' - ) + return send_file(buffer, mimetype="text/csv", as_attachment=True, download_name="timetracker_import_template.csv") diff --git a/app/routes/integrations.py b/app/routes/integrations.py index 215e9c9a..025efd3c 100644 --- a/app/routes/integrations.py +++ b/app/routes/integrations.py @@ -14,223 +14,215 @@ logger = logging.getLogger(__name__) -integrations_bp = Blueprint('integrations', __name__) +integrations_bp = Blueprint("integrations", __name__) -@integrations_bp.route('/integrations') +@integrations_bp.route("/integrations") @login_required def list_integrations(): """List all integrations for the current user.""" service = IntegrationService() integrations = service.list_integrations(current_user.id) available_providers = service.get_available_providers() - - return render_template( - 'integrations/list.html', - integrations=integrations, - available_providers=available_providers - ) + return render_template("integrations/list.html", integrations=integrations, available_providers=available_providers) -@integrations_bp.route('/integrations//connect', methods=['GET', 'POST']) + +@integrations_bp.route("/integrations//connect", methods=["GET", "POST"]) @login_required def connect_integration(provider): """Start OAuth flow for connecting an integration.""" service = IntegrationService() - + # Check if provider is available if provider not in service._connector_registry: - flash(_('Integration provider not available.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Integration provider not available."), "error") + return redirect(url_for("integrations.list_integrations")) + # Check if integration already exists - existing = Integration.query.filter_by( - provider=provider, - user_id=current_user.id - ).first() - + existing = Integration.query.filter_by(provider=provider, user_id=current_user.id).first() + if existing: # Use existing integration (allows reconnecting if credentials were removed) integration = existing else: # Create new integration if it doesn't exist result = service.create_integration(provider, current_user.id) - if not result['success']: - flash(result['message'], 'error') - return redirect(url_for('integrations.list_integrations')) - integration = result['integration'] - + if not result["success"]: + flash(result["message"], "error") + return redirect(url_for("integrations.list_integrations")) + integration = result["integration"] + # Get connector connector = service.get_connector(integration) if not connector: - flash(_('Could not initialize connector.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Could not initialize connector."), "error") + return redirect(url_for("integrations.list_integrations")) + # Generate state for CSRF protection state = secrets.token_urlsafe(32) - session[f'integration_oauth_state_{integration.id}'] = state - + session[f"integration_oauth_state_{integration.id}"] = state + # Get authorization URL try: - redirect_uri = url_for('integrations.oauth_callback', provider=provider, _external=True) + redirect_uri = url_for("integrations.oauth_callback", provider=provider, _external=True) auth_url = connector.get_authorization_url(redirect_uri, state=state) return redirect(auth_url) except ValueError as e: - flash(_('Integration not configured: {error}').format(error=str(e)), 'error') - return redirect(url_for('integrations.list_integrations')) + flash(_("Integration not configured: {error}").format(error=str(e)), "error") + return redirect(url_for("integrations.list_integrations")) -@integrations_bp.route('/integrations//callback') +@integrations_bp.route("/integrations//callback") @login_required def oauth_callback(provider): """Handle OAuth callback.""" service = IntegrationService() - - code = request.args.get('code') - state = request.args.get('state') - error = request.args.get('error') - + + code = request.args.get("code") + state = request.args.get("state") + error = request.args.get("error") + if error: - flash(_('Authorization failed: %(error)s', error=error), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Authorization failed: %(error)s", error=error), "error") + return redirect(url_for("integrations.list_integrations")) + if not code: - flash(_('Authorization code not received.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Authorization code not received."), "error") + return redirect(url_for("integrations.list_integrations")) + # Find integration for this user and provider - integration = Integration.query.filter_by( - provider=provider, - user_id=current_user.id - ).first() - + integration = Integration.query.filter_by(provider=provider, user_id=current_user.id).first() + if not integration: - flash(_('Integration not found.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Integration not found."), "error") + return redirect(url_for("integrations.list_integrations")) + # Verify state - session_key = f'integration_oauth_state_{integration.id}' + session_key = f"integration_oauth_state_{integration.id}" expected_state = session.get(session_key) if not expected_state or state != expected_state: - flash(_('Invalid state parameter. Please try again.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Invalid state parameter. Please try again."), "error") + return redirect(url_for("integrations.list_integrations")) + session.pop(session_key, None) - + # Get connector connector = service.get_connector(integration) if not connector: - flash(_('Could not initialize connector.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Could not initialize connector."), "error") + return redirect(url_for("integrations.list_integrations")) + try: # Exchange code for tokens - redirect_uri = url_for('integrations.oauth_callback', provider=provider, _external=True) + redirect_uri = url_for("integrations.oauth_callback", provider=provider, _external=True) tokens = connector.exchange_code_for_tokens(code, redirect_uri) - + # Save credentials service.save_credentials( integration_id=integration.id, - access_token=tokens.get('access_token'), - refresh_token=tokens.get('refresh_token'), - expires_at=tokens.get('expires_at'), - token_type=tokens.get('token_type', 'Bearer'), - scope=tokens.get('scope'), - extra_data=tokens.get('extra_data', {}) + access_token=tokens.get("access_token"), + refresh_token=tokens.get("refresh_token"), + expires_at=tokens.get("expires_at"), + token_type=tokens.get("token_type", "Bearer"), + scope=tokens.get("scope"), + extra_data=tokens.get("extra_data", {}), ) - + # Test connection test_result = service.test_connection(integration.id, current_user.id) - if test_result.get('success'): - flash(_('Integration connected successfully!'), 'success') + if test_result.get("success"): + flash(_("Integration connected successfully!"), "success") else: - flash(_('Integration connected but connection test failed: %(message)s', message=test_result.get('message', 'Unknown error')), 'warning') - - return redirect(url_for('integrations.view_integration', integration_id=integration.id)) - + flash( + _( + "Integration connected but connection test failed: %(message)s", + message=test_result.get("message", "Unknown error"), + ), + "warning", + ) + + return redirect(url_for("integrations.view_integration", integration_id=integration.id)) + except Exception as e: logger.error(f"Error in OAuth callback for {provider}: {e}") - flash(_('Error connecting integration: %(error)s', error=str(e)), 'error') - return redirect(url_for('integrations.list_integrations')) + flash(_("Error connecting integration: %(error)s", error=str(e)), "error") + return redirect(url_for("integrations.list_integrations")) -@integrations_bp.route('/integrations/') +@integrations_bp.route("/integrations/") @login_required def view_integration(integration_id): """View integration details.""" service = IntegrationService() integration = service.get_integration(integration_id, current_user.id) - + if not integration: - flash(_('Integration not found.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Integration not found."), "error") + return redirect(url_for("integrations.list_integrations")) + connector = service.get_connector(integration) credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first() - + return render_template( - 'integrations/view.html', - integration=integration, - connector=connector, - credentials=credentials + "integrations/view.html", integration=integration, connector=connector, credentials=credentials ) -@integrations_bp.route('/integrations//test', methods=['POST']) +@integrations_bp.route("/integrations//test", methods=["POST"]) @login_required def test_integration(integration_id): """Test integration connection.""" service = IntegrationService() result = service.test_connection(integration_id, current_user.id) - - if result.get('success'): - flash(_('Connection test successful!'), 'success') + + if result.get("success"): + flash(_("Connection test successful!"), "success") else: - flash(_('Connection test failed: %(message)s', message=result.get('message', 'Unknown error')), 'error') - - return redirect(url_for('integrations.view_integration', integration_id=integration_id)) + flash(_("Connection test failed: %(message)s", message=result.get("message", "Unknown error")), "error") + return redirect(url_for("integrations.view_integration", integration_id=integration_id)) -@integrations_bp.route('/integrations//delete', methods=['POST']) + +@integrations_bp.route("/integrations//delete", methods=["POST"]) @login_required def delete_integration(integration_id): """Delete an integration.""" service = IntegrationService() result = service.delete_integration(integration_id, current_user.id) - - if result['success']: - flash(_('Integration deleted successfully.'), 'success') + + if result["success"]: + flash(_("Integration deleted successfully."), "success") else: - flash(result['message'], 'error') - - return redirect(url_for('integrations.list_integrations')) + flash(result["message"], "error") + return redirect(url_for("integrations.list_integrations")) -@integrations_bp.route('/integrations//sync', methods=['POST']) + +@integrations_bp.route("/integrations//sync", methods=["POST"]) @login_required def sync_integration(integration_id): """Trigger a sync for an integration.""" service = IntegrationService() integration = service.get_integration(integration_id, current_user.id) - + if not integration: - flash(_('Integration not found.'), 'error') - return redirect(url_for('integrations.list_integrations')) - + flash(_("Integration not found."), "error") + return redirect(url_for("integrations.list_integrations")) + connector = service.get_connector(integration) if not connector: - flash(_('Connector not available.'), 'error') - return redirect(url_for('integrations.view_integration', integration_id=integration_id)) - + flash(_("Connector not available."), "error") + return redirect(url_for("integrations.view_integration", integration_id=integration_id)) + try: sync_result = connector.sync_data() - if sync_result.get('success'): - flash(_('Sync completed successfully.'), 'success') + if sync_result.get("success"): + flash(_("Sync completed successfully."), "success") else: - flash(_('Sync failed: %(message)s', message=sync_result.get('message', 'Unknown error')), 'error') + flash(_("Sync failed: %(message)s", message=sync_result.get("message", "Unknown error")), "error") except Exception as e: logger.error(f"Error syncing integration {integration_id}: {e}") - flash(_('Error during sync: %(error)s', error=str(e)), 'error') - - return redirect(url_for('integrations.view_integration', integration_id=integration_id)) + flash(_("Error during sync: %(error)s", error=str(e)), "error") + return redirect(url_for("integrations.view_integration", integration_id=integration_id)) diff --git a/app/routes/inventory.py b/app/routes/inventory.py index b203c175..610f6bac 100644 --- a/app/routes/inventory.py +++ b/app/routes/inventory.py @@ -1,12 +1,21 @@ """Inventory Management Routes""" + from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, log_event from app.models import ( - Warehouse, StockItem, WarehouseStock, StockMovement, StockReservation, - ProjectStockAllocation, Project, Supplier, SupplierStockItem, - PurchaseOrder, PurchaseOrderItem + Warehouse, + StockItem, + WarehouseStock, + StockMovement, + StockReservation, + ProjectStockAllocation, + Project, + Supplier, + SupplierStockItem, + PurchaseOrder, + PurchaseOrderItem, ) from datetime import datetime, timedelta from decimal import Decimal, InvalidOperation @@ -14,96 +23,94 @@ from app.utils.permissions import admin_or_permission_required from sqlalchemy import func, or_ -inventory_bp = Blueprint('inventory', __name__) +inventory_bp = Blueprint("inventory", __name__) # ==================== Stock Items API (for selection in forms) ==================== -@inventory_bp.route('/api/inventory/stock-items/search') + +@inventory_bp.route("/api/inventory/stock-items/search") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def search_stock_items(): """Search stock items for dropdown/autocomplete (returns JSON)""" - search = request.args.get('search', '').strip() - active_only = request.args.get('active_only', 'true').lower() == 'true' - + search = request.args.get("search", "").strip() + active_only = request.args.get("active_only", "true").lower() == "true" + query = StockItem.query.filter_by(is_active=True) if active_only else StockItem.query - + if search: like = f"%{search}%" - query = query.filter( - or_( - StockItem.sku.ilike(like), - StockItem.name.ilike(like), - StockItem.barcode.ilike(like) - ) - ) - + query = query.filter(or_(StockItem.sku.ilike(like), StockItem.name.ilike(like), StockItem.barcode.ilike(like))) + items = query.order_by(StockItem.name).limit(50).all() - - return jsonify({ - 'items': [{ - 'id': item.id, - 'sku': item.sku, - 'name': item.name, - 'default_price': float(item.default_price) if item.default_price else None, - 'default_cost': float(item.default_cost) if item.default_cost else None, - 'unit': item.unit, - 'description': item.description, - 'is_trackable': item.is_trackable, - 'currency_code': item.currency_code - } for item in items] - }) - - -@inventory_bp.route('/api/inventory/stock-items//availability') + + return jsonify( + { + "items": [ + { + "id": item.id, + "sku": item.sku, + "name": item.name, + "default_price": float(item.default_price) if item.default_price else None, + "default_cost": float(item.default_cost) if item.default_cost else None, + "unit": item.unit, + "description": item.description, + "is_trackable": item.is_trackable, + "currency_code": item.currency_code, + } + for item in items + ] + } + ) + + +@inventory_bp.route("/api/inventory/stock-items//availability") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def get_item_availability(item_id): """Get stock availability for a specific item across warehouses""" item = StockItem.query.get_or_404(item_id) - warehouse_id = request.args.get('warehouse_id', type=int) - + warehouse_id = request.args.get("warehouse_id", type=int) + query = WarehouseStock.query.filter_by(stock_item_id=item_id) if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + stock_levels = query.all() - + availability = [] for stock in stock_levels: - availability.append({ - 'warehouse_id': stock.warehouse_id, - 'warehouse_code': stock.warehouse.code, - 'warehouse_name': stock.warehouse.name, - 'quantity_available': float(stock.quantity_available) - }) - - return jsonify({ - 'item_id': item_id, - 'item_sku': item.sku, - 'item_name': item.name, - 'availability': availability - }) + availability.append( + { + "warehouse_id": stock.warehouse_id, + "warehouse_code": stock.warehouse.code, + "warehouse_name": stock.warehouse.name, + "quantity_available": float(stock.quantity_available), + } + ) + + return jsonify({"item_id": item_id, "item_sku": item.sku, "item_name": item.name, "availability": availability}) # ==================== Stock Items ==================== -@inventory_bp.route('/inventory/items') + +@inventory_bp.route("/inventory/items") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def list_stock_items(): """List all stock items""" - search = request.args.get('search', '').strip() - category = request.args.get('category', '') - active_only = request.args.get('active', 'true').lower() == 'true' - low_stock_only = request.args.get('low_stock', 'false').lower() == 'true' - + search = request.args.get("search", "").strip() + category = request.args.get("category", "") + active_only = request.args.get("active", "true").lower() == "true" + low_stock_only = request.args.get("low_stock", "false").lower() == "true" + query = StockItem.query - + if active_only: query = query.filter_by(is_active=True) - + if search: like = f"%{search}%" query = query.filter( @@ -111,207 +118,251 @@ def list_stock_items(): StockItem.sku.ilike(like), StockItem.name.ilike(like), StockItem.barcode.ilike(like), - StockItem.description.ilike(like) + StockItem.description.ilike(like), ) ) - + if category: query = query.filter_by(category=category) - + items = query.order_by(StockItem.name).all() - + # Filter low stock items if requested if low_stock_only: items = [item for item in items if item.is_low_stock] - + # Get categories for filter dropdown - categories = db.session.query(StockItem.category).distinct().filter( - StockItem.category.isnot(None) - ).order_by(StockItem.category).all() + categories = ( + db.session.query(StockItem.category) + .distinct() + .filter(StockItem.category.isnot(None)) + .order_by(StockItem.category) + .all() + ) categories = [cat[0] for cat in categories] - - return render_template('inventory/stock_items/list.html', - items=items, - search=search, - category=category, - active_only=active_only, - low_stock_only=low_stock_only, - categories=categories) + return render_template( + "inventory/stock_items/list.html", + items=items, + search=search, + category=category, + active_only=active_only, + low_stock_only=low_stock_only, + categories=categories, + ) -@inventory_bp.route('/inventory/items/new', methods=['GET', 'POST']) + +@inventory_bp.route("/inventory/items/new", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_stock_items') +@admin_or_permission_required("manage_stock_items") def new_stock_item(): """Create a new stock item""" - if request.method == 'POST': + if request.method == "POST": try: - sku = request.form.get('sku', '').strip().upper() - name = request.form.get('name', '').strip() - + sku = request.form.get("sku", "").strip().upper() + name = request.form.get("name", "").strip() + # Check if SKU already exists existing = StockItem.query.filter_by(sku=sku).first() if existing: - flash(_('SKU already exists. Please use a different SKU.'), 'error') - return render_template('inventory/stock_items/form.html', item=None, error='sku_exists') - + flash(_("SKU already exists. Please use a different SKU."), "error") + return render_template("inventory/stock_items/form.html", item=None, error="sku_exists") + item = StockItem( sku=sku, name=name, created_by=current_user.id, - description=request.form.get('description', '').strip() or None, - category=request.form.get('category', '').strip() or None, - unit=request.form.get('unit', 'pcs').strip(), - default_cost=request.form.get('default_cost') or None, - default_price=request.form.get('default_price') or None, - currency_code=request.form.get('currency_code', 'EUR').upper(), - barcode=request.form.get('barcode', '').strip() or None, - is_active=request.form.get('is_active') == 'on', - is_trackable=request.form.get('is_trackable') != 'off', - reorder_point=request.form.get('reorder_point') or None, - reorder_quantity=request.form.get('reorder_quantity') or None, - supplier=request.form.get('supplier', '').strip() or None, - supplier_sku=request.form.get('supplier_sku', '').strip() or None, - image_url=request.form.get('image_url', '').strip() or None, - notes=request.form.get('notes', '').strip() or None + description=request.form.get("description", "").strip() or None, + category=request.form.get("category", "").strip() or None, + unit=request.form.get("unit", "pcs").strip(), + default_cost=request.form.get("default_cost") or None, + default_price=request.form.get("default_price") or None, + currency_code=request.form.get("currency_code", "EUR").upper(), + barcode=request.form.get("barcode", "").strip() or None, + is_active=request.form.get("is_active") == "on", + is_trackable=request.form.get("is_trackable") != "off", + reorder_point=request.form.get("reorder_point") or None, + reorder_quantity=request.form.get("reorder_quantity") or None, + supplier=request.form.get("supplier", "").strip() or None, + supplier_sku=request.form.get("supplier_sku", "").strip() or None, + image_url=request.form.get("image_url", "").strip() or None, + notes=request.form.get("notes", "").strip() or None, ) - + db.session.add(item) safe_commit() - + # Handle suppliers - supplier_ids = request.form.getlist('supplier_id[]') - supplier_skus = request.form.getlist('supplier_sku[]') - supplier_unit_costs = request.form.getlist('supplier_unit_cost[]') - supplier_moqs = request.form.getlist('supplier_moq[]') - supplier_lead_times = request.form.getlist('supplier_lead_time[]') - supplier_preferred = request.form.getlist('supplier_preferred[]') - + supplier_ids = request.form.getlist("supplier_id[]") + supplier_skus = request.form.getlist("supplier_sku[]") + supplier_unit_costs = request.form.getlist("supplier_unit_cost[]") + supplier_moqs = request.form.getlist("supplier_moq[]") + supplier_lead_times = request.form.getlist("supplier_lead_time[]") + supplier_preferred = request.form.getlist("supplier_preferred[]") + for i, supplier_id in enumerate(supplier_ids): if supplier_id and supplier_id.strip(): try: supplier_stock_item = SupplierStockItem( supplier_id=int(supplier_id), stock_item_id=item.id, - supplier_sku=supplier_skus[i].strip() if i < len(supplier_skus) and supplier_skus[i] else None, - unit_cost=Decimal(supplier_unit_costs[i]) if i < len(supplier_unit_costs) and supplier_unit_costs[i] else None, - minimum_order_quantity=Decimal(supplier_moqs[i]) if i < len(supplier_moqs) and supplier_moqs[i] else None, - lead_time_days=int(supplier_lead_times[i]) if i < len(supplier_lead_times) and supplier_lead_times[i] else None, + supplier_sku=( + supplier_skus[i].strip() if i < len(supplier_skus) and supplier_skus[i] else None + ), + unit_cost=( + Decimal(supplier_unit_costs[i]) + if i < len(supplier_unit_costs) and supplier_unit_costs[i] + else None + ), + minimum_order_quantity=( + Decimal(supplier_moqs[i]) if i < len(supplier_moqs) and supplier_moqs[i] else None + ), + lead_time_days=( + int(supplier_lead_times[i]) + if i < len(supplier_lead_times) and supplier_lead_times[i] + else None + ), is_preferred=str(item.id) in supplier_preferred if supplier_preferred else False, - currency_code=item.currency_code + currency_code=item.currency_code, ) db.session.add(supplier_stock_item) except (ValueError, InvalidOperation): pass # Skip invalid entries - + safe_commit() - - log_event('stock_item_created', {'stock_item_id': item.id, 'sku': item.sku}) - flash(_('Stock item created successfully.'), 'success') - return redirect(url_for('inventory.view_stock_item', item_id=item.id)) - + + log_event("stock_item_created", {"stock_item_id": item.id, "sku": item.sku}) + flash(_("Stock item created successfully."), "success") + return redirect(url_for("inventory.view_stock_item", item_id=item.id)) + except Exception as e: db.session.rollback() - flash(_('Error creating stock item: %(error)s', error=str(e)), 'error') - return render_template('inventory/stock_items/form.html', item=None) - - return render_template('inventory/stock_items/form.html', item=None) + flash(_("Error creating stock item: %(error)s", error=str(e)), "error") + return render_template("inventory/stock_items/form.html", item=None) + + return render_template("inventory/stock_items/form.html", item=None) -@inventory_bp.route('/inventory/items/') +@inventory_bp.route("/inventory/items/") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def view_stock_item(item_id): """View stock item details""" item = StockItem.query.get_or_404(item_id) - + # Get stock levels across all warehouses stock_levels = WarehouseStock.query.filter_by(stock_item_id=item_id).all() - + # Get recent movements (last 20) - recent_movements = StockMovement.query.filter_by(stock_item_id=item_id)\ - .order_by(StockMovement.moved_at.desc()).limit(20).all() - + recent_movements = ( + StockMovement.query.filter_by(stock_item_id=item_id).order_by(StockMovement.moved_at.desc()).limit(20).all() + ) + # Get active reservations active_reservations = StockReservation.query.filter( - StockReservation.stock_item_id == item_id, - StockReservation.status == 'reserved' + StockReservation.stock_item_id == item_id, StockReservation.status == "reserved" ).all() - - return render_template('inventory/stock_items/view.html', - item=item, - stock_levels=stock_levels, - recent_movements=recent_movements, - active_reservations=active_reservations) + + return render_template( + "inventory/stock_items/view.html", + item=item, + stock_levels=stock_levels, + recent_movements=recent_movements, + active_reservations=active_reservations, + ) -@inventory_bp.route('/inventory/items//edit', methods=['GET', 'POST']) +@inventory_bp.route("/inventory/items//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_stock_items') +@admin_or_permission_required("manage_stock_items") def edit_stock_item(item_id): """Edit stock item""" item = StockItem.query.get_or_404(item_id) - - if request.method == 'POST': + + if request.method == "POST": try: # Check if SKU is being changed and if new SKU exists - new_sku = request.form.get('sku', '').strip().upper() + new_sku = request.form.get("sku", "").strip().upper() if new_sku != item.sku: existing = StockItem.query.filter_by(sku=new_sku).first() if existing: - flash(_('SKU already exists. Please use a different SKU.'), 'error') + flash(_("SKU already exists. Please use a different SKU."), "error") suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all() - return render_template('inventory/stock_items/form.html', item=item, suppliers=suppliers) - + return render_template("inventory/stock_items/form.html", item=item, suppliers=suppliers) + item.sku = new_sku - item.name = request.form.get('name', '').strip() - item.description = request.form.get('description', '').strip() or None - item.category = request.form.get('category', '').strip() or None - item.unit = request.form.get('unit', 'pcs').strip() - item.default_cost = Decimal(request.form.get('default_cost')) if request.form.get('default_cost') else None - item.default_price = Decimal(request.form.get('default_price')) if request.form.get('default_price') else None - item.currency_code = request.form.get('currency_code', 'EUR').upper() - item.barcode = request.form.get('barcode', '').strip() or None - item.is_active = request.form.get('is_active') == 'on' - item.is_trackable = request.form.get('is_trackable') != 'off' - item.reorder_point = Decimal(request.form.get('reorder_point')) if request.form.get('reorder_point') else None - item.reorder_quantity = Decimal(request.form.get('reorder_quantity')) if request.form.get('reorder_quantity') else None - item.supplier = request.form.get('supplier', '').strip() or None - item.supplier_sku = request.form.get('supplier_sku', '').strip() or None - item.image_url = request.form.get('image_url', '').strip() or None - item.notes = request.form.get('notes', '').strip() or None + item.name = request.form.get("name", "").strip() + item.description = request.form.get("description", "").strip() or None + item.category = request.form.get("category", "").strip() or None + item.unit = request.form.get("unit", "pcs").strip() + item.default_cost = Decimal(request.form.get("default_cost")) if request.form.get("default_cost") else None + item.default_price = ( + Decimal(request.form.get("default_price")) if request.form.get("default_price") else None + ) + item.currency_code = request.form.get("currency_code", "EUR").upper() + item.barcode = request.form.get("barcode", "").strip() or None + item.is_active = request.form.get("is_active") == "on" + item.is_trackable = request.form.get("is_trackable") != "off" + item.reorder_point = ( + Decimal(request.form.get("reorder_point")) if request.form.get("reorder_point") else None + ) + item.reorder_quantity = ( + Decimal(request.form.get("reorder_quantity")) if request.form.get("reorder_quantity") else None + ) + item.supplier = request.form.get("supplier", "").strip() or None + item.supplier_sku = request.form.get("supplier_sku", "").strip() or None + item.image_url = request.form.get("image_url", "").strip() or None + item.notes = request.form.get("notes", "").strip() or None item.updated_at = datetime.utcnow() - + # Handle suppliers - update existing or create new # First, get all existing supplier items for this stock item - supplier_item_ids = request.form.getlist('supplier_item_id[]') - supplier_ids = request.form.getlist('supplier_id[]') - supplier_skus = request.form.getlist('supplier_sku[]') - supplier_unit_costs = request.form.getlist('supplier_unit_cost[]') - supplier_moqs = request.form.getlist('supplier_moq[]') - supplier_lead_times = request.form.getlist('supplier_lead_time[]') - supplier_preferred = request.form.getlist('supplier_preferred[]') - + supplier_item_ids = request.form.getlist("supplier_item_id[]") + supplier_ids = request.form.getlist("supplier_id[]") + supplier_skus = request.form.getlist("supplier_sku[]") + supplier_unit_costs = request.form.getlist("supplier_unit_cost[]") + supplier_moqs = request.form.getlist("supplier_moq[]") + supplier_lead_times = request.form.getlist("supplier_lead_time[]") + supplier_preferred = request.form.getlist("supplier_preferred[]") + # Get existing supplier items - existing_supplier_items = {si.id: si for si in SupplierStockItem.query.filter_by(stock_item_id=item.id).all()} + existing_supplier_items = { + si.id: si for si in SupplierStockItem.query.filter_by(stock_item_id=item.id).all() + } processed_ids = set() - + for i, supplier_id in enumerate(supplier_ids): if supplier_id and supplier_id.strip(): try: - supplier_item_id = supplier_item_ids[i] if i < len(supplier_item_ids) and supplier_item_ids[i] else None - + supplier_item_id = ( + supplier_item_ids[i] if i < len(supplier_item_ids) and supplier_item_ids[i] else None + ) + if supplier_item_id and supplier_item_id.strip(): # Update existing supplier_item_id_int = int(supplier_item_id) if supplier_item_id_int in existing_supplier_items: supplier_item = existing_supplier_items[supplier_item_id_int] supplier_item.supplier_id = int(supplier_id) - supplier_item.supplier_sku = supplier_skus[i].strip() if i < len(supplier_skus) and supplier_skus[i] else None - supplier_item.unit_cost = Decimal(supplier_unit_costs[i]) if i < len(supplier_unit_costs) and supplier_unit_costs[i] else None - supplier_item.minimum_order_quantity = Decimal(supplier_moqs[i]) if i < len(supplier_moqs) and supplier_moqs[i] else None - supplier_item.lead_time_days = int(supplier_lead_times[i]) if i < len(supplier_lead_times) and supplier_lead_times[i] else None - supplier_item.is_preferred = supplier_item_id in supplier_preferred if supplier_preferred else False + supplier_item.supplier_sku = ( + supplier_skus[i].strip() if i < len(supplier_skus) and supplier_skus[i] else None + ) + supplier_item.unit_cost = ( + Decimal(supplier_unit_costs[i]) + if i < len(supplier_unit_costs) and supplier_unit_costs[i] + else None + ) + supplier_item.minimum_order_quantity = ( + Decimal(supplier_moqs[i]) if i < len(supplier_moqs) and supplier_moqs[i] else None + ) + supplier_item.lead_time_days = ( + int(supplier_lead_times[i]) + if i < len(supplier_lead_times) and supplier_lead_times[i] + else None + ) + supplier_item.is_preferred = ( + supplier_item_id in supplier_preferred if supplier_preferred else False + ) supplier_item.updated_at = datetime.utcnow() processed_ids.add(supplier_item_id_int) else: @@ -319,354 +370,391 @@ def edit_stock_item(item_id): supplier_stock_item = SupplierStockItem( supplier_id=int(supplier_id), stock_item_id=item.id, - supplier_sku=supplier_skus[i].strip() if i < len(supplier_skus) and supplier_skus[i] else None, - unit_cost=Decimal(supplier_unit_costs[i]) if i < len(supplier_unit_costs) and supplier_unit_costs[i] else None, - minimum_order_quantity=Decimal(supplier_moqs[i]) if i < len(supplier_moqs) and supplier_moqs[i] else None, - lead_time_days=int(supplier_lead_times[i]) if i < len(supplier_lead_times) and supplier_lead_times[i] else None, + supplier_sku=( + supplier_skus[i].strip() if i < len(supplier_skus) and supplier_skus[i] else None + ), + unit_cost=( + Decimal(supplier_unit_costs[i]) + if i < len(supplier_unit_costs) and supplier_unit_costs[i] + else None + ), + minimum_order_quantity=( + Decimal(supplier_moqs[i]) if i < len(supplier_moqs) and supplier_moqs[i] else None + ), + lead_time_days=( + int(supplier_lead_times[i]) + if i < len(supplier_lead_times) and supplier_lead_times[i] + else None + ), is_preferred=False, - currency_code=item.currency_code + currency_code=item.currency_code, ) db.session.add(supplier_stock_item) except (ValueError, InvalidOperation): pass # Skip invalid entries - + # Deactivate removed supplier items for supplier_item_id, supplier_item in existing_supplier_items.items(): if supplier_item_id not in processed_ids: supplier_item.is_active = False supplier_item.updated_at = datetime.utcnow() - + safe_commit() - - log_event('stock_item_updated', {'stock_item_id': item.id}) - flash(_('Stock item updated successfully.'), 'success') - return redirect(url_for('inventory.view_stock_item', item_id=item.id)) - + + log_event("stock_item_updated", {"stock_item_id": item.id}) + flash(_("Stock item updated successfully."), "success") + return redirect(url_for("inventory.view_stock_item", item_id=item.id)) + except Exception as e: db.session.rollback() - flash(_('Error updating stock item: %(error)s', error=str(e)), 'error') - + flash(_("Error updating stock item: %(error)s", error=str(e)), "error") + suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all() - return render_template('inventory/stock_items/form.html', item=item, suppliers=suppliers) + return render_template("inventory/stock_items/form.html", item=item, suppliers=suppliers) -@inventory_bp.route('/inventory/items//delete', methods=['POST']) +@inventory_bp.route("/inventory/items//delete", methods=["POST"]) @login_required -@admin_or_permission_required('manage_stock_items') +@admin_or_permission_required("manage_stock_items") def delete_stock_item(item_id): """Delete stock item""" item = StockItem.query.get_or_404(item_id) - + # Check if item has any stock or movements has_stock = WarehouseStock.query.filter_by(stock_item_id=item_id).first() has_movements = StockMovement.query.filter_by(stock_item_id=item_id).first() - + if has_stock or has_movements: - flash(_('Cannot delete stock item with existing stock or movement history.'), 'error') - return redirect(url_for('inventory.view_stock_item', item_id=item_id)) - + flash(_("Cannot delete stock item with existing stock or movement history."), "error") + return redirect(url_for("inventory.view_stock_item", item_id=item_id)) + try: db.session.delete(item) safe_commit() - - log_event('stock_item_deleted', {'stock_item_id': item_id, 'sku': item.sku}) - flash(_('Stock item deleted successfully.'), 'success') - return redirect(url_for('inventory.list_stock_items')) + + log_event("stock_item_deleted", {"stock_item_id": item_id, "sku": item.sku}) + flash(_("Stock item deleted successfully."), "success") + return redirect(url_for("inventory.list_stock_items")) except Exception as e: db.session.rollback() - flash(_('Error deleting stock item: %(error)s', error=str(e)), 'error') - return redirect(url_for('inventory.view_stock_item', item_id=item_id)) + flash(_("Error deleting stock item: %(error)s", error=str(e)), "error") + return redirect(url_for("inventory.view_stock_item", item_id=item_id)) # ==================== Warehouses ==================== -@inventory_bp.route('/inventory/warehouses') + +@inventory_bp.route("/inventory/warehouses") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def list_warehouses(): """List all warehouses""" - active_only = request.args.get('active', 'true').lower() == 'true' - + active_only = request.args.get("active", "true").lower() == "true" + query = Warehouse.query - + if active_only: query = query.filter_by(is_active=True) - + warehouses = query.order_by(Warehouse.code).all() - - return render_template('inventory/warehouses/list.html', - warehouses=warehouses, - active_only=active_only) + + return render_template("inventory/warehouses/list.html", warehouses=warehouses, active_only=active_only) -@inventory_bp.route('/inventory/warehouses/new', methods=['GET', 'POST']) +@inventory_bp.route("/inventory/warehouses/new", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_warehouses') +@admin_or_permission_required("manage_warehouses") def new_warehouse(): """Create a new warehouse""" - if request.method == 'POST': + if request.method == "POST": try: - code = request.form.get('code', '').strip().upper() - + code = request.form.get("code", "").strip().upper() + # Check if code already exists existing = Warehouse.query.filter_by(code=code).first() if existing: - flash(_('Warehouse code already exists. Please use a different code.'), 'error') - return render_template('inventory/warehouses/form.html', warehouse=None) - + flash(_("Warehouse code already exists. Please use a different code."), "error") + return render_template("inventory/warehouses/form.html", warehouse=None) + warehouse = Warehouse( - name=request.form.get('name', '').strip(), + name=request.form.get("name", "").strip(), code=code, created_by=current_user.id, - address=request.form.get('address', '').strip() or None, - contact_person=request.form.get('contact_person', '').strip() or None, - contact_email=request.form.get('contact_email', '').strip() or None, - contact_phone=request.form.get('contact_phone', '').strip() or None, - is_active=request.form.get('is_active') == 'on', - notes=request.form.get('notes', '').strip() or None + address=request.form.get("address", "").strip() or None, + contact_person=request.form.get("contact_person", "").strip() or None, + contact_email=request.form.get("contact_email", "").strip() or None, + contact_phone=request.form.get("contact_phone", "").strip() or None, + is_active=request.form.get("is_active") == "on", + notes=request.form.get("notes", "").strip() or None, ) - + db.session.add(warehouse) safe_commit() - - log_event('warehouse_created', {'warehouse_id': warehouse.id}) - flash(_('Warehouse created successfully.'), 'success') - return redirect(url_for('inventory.view_warehouse', warehouse_id=warehouse.id)) - + + log_event("warehouse_created", {"warehouse_id": warehouse.id}) + flash(_("Warehouse created successfully."), "success") + return redirect(url_for("inventory.view_warehouse", warehouse_id=warehouse.id)) + except Exception as e: db.session.rollback() - flash(_('Error creating warehouse: %(error)s', error=str(e)), 'error') - - return render_template('inventory/warehouses/form.html', warehouse=None) + flash(_("Error creating warehouse: %(error)s", error=str(e)), "error") + + return render_template("inventory/warehouses/form.html", warehouse=None) -@inventory_bp.route('/inventory/warehouses/') +@inventory_bp.route("/inventory/warehouses/") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def view_warehouse(warehouse_id): """View warehouse details""" warehouse = Warehouse.query.get_or_404(warehouse_id) - + # Get stock levels in this warehouse - stock_levels = WarehouseStock.query.filter_by(warehouse_id=warehouse_id)\ - .join(StockItem).order_by(StockItem.name).all() - + stock_levels = ( + WarehouseStock.query.filter_by(warehouse_id=warehouse_id).join(StockItem).order_by(StockItem.name).all() + ) + # Get recent movements - recent_movements = StockMovement.query.filter_by(warehouse_id=warehouse_id)\ - .order_by(StockMovement.moved_at.desc()).limit(20).all() - - return render_template('inventory/warehouses/view.html', - warehouse=warehouse, - stock_levels=stock_levels, - recent_movements=recent_movements) + recent_movements = ( + StockMovement.query.filter_by(warehouse_id=warehouse_id).order_by(StockMovement.moved_at.desc()).limit(20).all() + ) + return render_template( + "inventory/warehouses/view.html", + warehouse=warehouse, + stock_levels=stock_levels, + recent_movements=recent_movements, + ) -@inventory_bp.route('/inventory/warehouses//edit', methods=['GET', 'POST']) + +@inventory_bp.route("/inventory/warehouses//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_warehouses') +@admin_or_permission_required("manage_warehouses") def edit_warehouse(warehouse_id): """Edit warehouse""" warehouse = Warehouse.query.get_or_404(warehouse_id) - - if request.method == 'POST': + + if request.method == "POST": try: # Check if code is being changed - new_code = request.form.get('code', '').strip().upper() + new_code = request.form.get("code", "").strip().upper() if new_code != warehouse.code: existing = Warehouse.query.filter_by(code=new_code).first() if existing: - flash(_('Warehouse code already exists. Please use a different code.'), 'error') - return render_template('inventory/warehouses/form.html', warehouse=warehouse) - - warehouse.name = request.form.get('name', '').strip() + flash(_("Warehouse code already exists. Please use a different code."), "error") + return render_template("inventory/warehouses/form.html", warehouse=warehouse) + + warehouse.name = request.form.get("name", "").strip() warehouse.code = new_code - warehouse.address = request.form.get('address', '').strip() or None - warehouse.contact_person = request.form.get('contact_person', '').strip() or None - warehouse.contact_email = request.form.get('contact_email', '').strip() or None - warehouse.contact_phone = request.form.get('contact_phone', '').strip() or None - warehouse.is_active = request.form.get('is_active') == 'on' - warehouse.notes = request.form.get('notes', '').strip() or None + warehouse.address = request.form.get("address", "").strip() or None + warehouse.contact_person = request.form.get("contact_person", "").strip() or None + warehouse.contact_email = request.form.get("contact_email", "").strip() or None + warehouse.contact_phone = request.form.get("contact_phone", "").strip() or None + warehouse.is_active = request.form.get("is_active") == "on" + warehouse.notes = request.form.get("notes", "").strip() or None warehouse.updated_at = datetime.utcnow() - + safe_commit() - - log_event('warehouse_updated', {'warehouse_id': warehouse.id}) - flash(_('Warehouse updated successfully.'), 'success') - return redirect(url_for('inventory.view_warehouse', warehouse_id=warehouse.id)) - + + log_event("warehouse_updated", {"warehouse_id": warehouse.id}) + flash(_("Warehouse updated successfully."), "success") + return redirect(url_for("inventory.view_warehouse", warehouse_id=warehouse.id)) + except Exception as e: db.session.rollback() - flash(_('Error updating warehouse: %(error)s', error=str(e)), 'error') - - return render_template('inventory/warehouses/form.html', warehouse=warehouse) + flash(_("Error updating warehouse: %(error)s", error=str(e)), "error") + + return render_template("inventory/warehouses/form.html", warehouse=warehouse) -@inventory_bp.route('/inventory/warehouses//delete', methods=['POST']) +@inventory_bp.route("/inventory/warehouses//delete", methods=["POST"]) @login_required -@admin_or_permission_required('manage_warehouses') +@admin_or_permission_required("manage_warehouses") def delete_warehouse(warehouse_id): """Delete warehouse""" warehouse = Warehouse.query.get_or_404(warehouse_id) - + # Check if warehouse has stock has_stock = WarehouseStock.query.filter_by(warehouse_id=warehouse_id).first() - + if has_stock: - flash(_('Cannot delete warehouse with existing stock. Please transfer or remove all stock first.'), 'error') - return redirect(url_for('inventory.view_warehouse', warehouse_id=warehouse_id)) - + flash(_("Cannot delete warehouse with existing stock. Please transfer or remove all stock first."), "error") + return redirect(url_for("inventory.view_warehouse", warehouse_id=warehouse_id)) + try: db.session.delete(warehouse) safe_commit() - - log_event('warehouse_deleted', {'warehouse_id': warehouse_id}) - flash(_('Warehouse deleted successfully.'), 'success') - return redirect(url_for('inventory.list_warehouses')) + + log_event("warehouse_deleted", {"warehouse_id": warehouse_id}) + flash(_("Warehouse deleted successfully."), "success") + return redirect(url_for("inventory.list_warehouses")) except Exception as e: db.session.rollback() - flash(_('Error deleting warehouse: %(error)s', error=str(e)), 'error') - return redirect(url_for('inventory.view_warehouse', warehouse_id=warehouse_id)) + flash(_("Error deleting warehouse: %(error)s", error=str(e)), "error") + return redirect(url_for("inventory.view_warehouse", warehouse_id=warehouse_id)) # ==================== Stock Levels ==================== -@inventory_bp.route('/inventory/stock-levels') + +@inventory_bp.route("/inventory/stock-levels") @login_required -@admin_or_permission_required('view_stock_levels') +@admin_or_permission_required("view_stock_levels") def stock_levels(): """View stock levels across all warehouses""" - warehouse_id = request.args.get('warehouse_id', type=int) - category = request.args.get('category', '') - low_stock_only = request.args.get('low_stock', 'false').lower() == 'true' - + warehouse_id = request.args.get("warehouse_id", type=int) + category = request.args.get("category", "") + low_stock_only = request.args.get("low_stock", "false").lower() == "true" + query = WarehouseStock.query.join(StockItem).join(Warehouse) - + if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + if category: query = query.filter(StockItem.category == category) - + stock_levels = query.order_by(Warehouse.code, StockItem.name).all() - + # Filter low stock if requested if low_stock_only: - stock_levels = [sl for sl in stock_levels if sl.stock_item.reorder_point and sl.quantity_on_hand < sl.stock_item.reorder_point] - + stock_levels = [ + sl + for sl in stock_levels + if sl.stock_item.reorder_point and sl.quantity_on_hand < sl.stock_item.reorder_point + ] + # Get warehouses and categories for filters warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - categories = db.session.query(StockItem.category).distinct().filter( - StockItem.category.isnot(None) - ).order_by(StockItem.category).all() + categories = ( + db.session.query(StockItem.category) + .distinct() + .filter(StockItem.category.isnot(None)) + .order_by(StockItem.category) + .all() + ) categories = [cat[0] for cat in categories] - - return render_template('inventory/stock_levels/list.html', - stock_levels=stock_levels, - warehouses=warehouses, - categories=categories, - selected_warehouse_id=warehouse_id, - selected_category=category, - low_stock_only=low_stock_only) + + return render_template( + "inventory/stock_levels/list.html", + stock_levels=stock_levels, + warehouses=warehouses, + categories=categories, + selected_warehouse_id=warehouse_id, + selected_category=category, + low_stock_only=low_stock_only, + ) -@inventory_bp.route('/inventory/stock-levels/warehouse/') +@inventory_bp.route("/inventory/stock-levels/warehouse/") @login_required -@admin_or_permission_required('view_stock_levels') +@admin_or_permission_required("view_stock_levels") def stock_levels_by_warehouse(warehouse_id): """View stock levels for a specific warehouse""" warehouse = Warehouse.query.get_or_404(warehouse_id) - category = request.args.get('category', '') - low_stock_only = request.args.get('low_stock', 'false').lower() == 'true' - + category = request.args.get("category", "") + low_stock_only = request.args.get("low_stock", "false").lower() == "true" + query = WarehouseStock.query.filter_by(warehouse_id=warehouse_id).join(StockItem) - + if category: query = query.filter(StockItem.category == category) - + stock_levels = query.order_by(StockItem.name).all() - + # Filter low stock if requested if low_stock_only: - stock_levels = [sl for sl in stock_levels if sl.stock_item.reorder_point and sl.quantity_on_hand < sl.stock_item.reorder_point] - + stock_levels = [ + sl + for sl in stock_levels + if sl.stock_item.reorder_point and sl.quantity_on_hand < sl.stock_item.reorder_point + ] + # Get categories for filter - categories = db.session.query(StockItem.category).distinct().filter( - StockItem.category.isnot(None) - ).order_by(StockItem.category).all() + categories = ( + db.session.query(StockItem.category) + .distinct() + .filter(StockItem.category.isnot(None)) + .order_by(StockItem.category) + .all() + ) categories = [cat[0] for cat in categories] - - return render_template('inventory/stock_levels/warehouse.html', - warehouse=warehouse, - stock_levels=stock_levels, - categories=categories, - selected_category=category, - low_stock_only=low_stock_only) + + return render_template( + "inventory/stock_levels/warehouse.html", + warehouse=warehouse, + stock_levels=stock_levels, + categories=categories, + selected_category=category, + low_stock_only=low_stock_only, + ) -@inventory_bp.route('/inventory/stock-levels/item/') +@inventory_bp.route("/inventory/stock-levels/item/") @login_required -@admin_or_permission_required('view_stock_levels') +@admin_or_permission_required("view_stock_levels") def stock_levels_by_item(item_id): """View stock levels for a specific item across all warehouses""" item = StockItem.query.get_or_404(item_id) - + stock_levels = WarehouseStock.query.filter_by(stock_item_id=item_id).join(Warehouse).order_by(Warehouse.code).all() - - return render_template('inventory/stock_levels/item.html', - item=item, - stock_levels=stock_levels) + + return render_template("inventory/stock_levels/item.html", item=item, stock_levels=stock_levels) # ==================== Stock Movements ==================== -@inventory_bp.route('/inventory/movements') + +@inventory_bp.route("/inventory/movements") @login_required -@admin_or_permission_required('view_stock_history') +@admin_or_permission_required("view_stock_history") def list_movements(): """List stock movements""" - movement_type = request.args.get('type', '') - stock_item_id = request.args.get('item_id', type=int) - warehouse_id = request.args.get('warehouse_id', type=int) - reference_type = request.args.get('reference_type', '') - + movement_type = request.args.get("type", "") + stock_item_id = request.args.get("item_id", type=int) + warehouse_id = request.args.get("warehouse_id", type=int) + reference_type = request.args.get("reference_type", "") + query = StockMovement.query - + if movement_type: query = query.filter_by(movement_type=movement_type) - + if stock_item_id: query = query.filter_by(stock_item_id=stock_item_id) - + if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + if reference_type: query = query.filter_by(reference_type=reference_type) - + movements = query.order_by(StockMovement.moved_at.desc()).limit(100).all() - - return render_template('inventory/movements/list.html', - movements=movements, - movement_type=movement_type, - stock_item_id=stock_item_id, - warehouse_id=warehouse_id, - reference_type=reference_type) + + return render_template( + "inventory/movements/list.html", + movements=movements, + movement_type=movement_type, + stock_item_id=stock_item_id, + warehouse_id=warehouse_id, + reference_type=reference_type, + ) -@inventory_bp.route('/inventory/movements/new', methods=['GET', 'POST']) +@inventory_bp.route("/inventory/movements/new", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_stock_movements') +@admin_or_permission_required("manage_stock_movements") def new_movement(): """Create a stock movement/adjustment""" - if request.method == 'POST': + if request.method == "POST": try: - movement_type = request.form.get('movement_type', 'adjustment') - stock_item_id = int(request.form.get('stock_item_id')) - warehouse_id = int(request.form.get('warehouse_id')) - quantity = Decimal(request.form.get('quantity')) - reason = request.form.get('reason', '').strip() or None - notes = request.form.get('notes', '').strip() or None - + movement_type = request.form.get("movement_type", "adjustment") + stock_item_id = int(request.form.get("stock_item_id")) + warehouse_id = int(request.form.get("warehouse_id")) + quantity = Decimal(request.form.get("quantity")) + reason = request.form.get("reason", "").strip() or None + notes = request.form.get("notes", "").strip() or None + movement, updated_stock = StockMovement.record_movement( movement_type=movement_type, stock_item_id=stock_item_id, @@ -675,1110 +763,1194 @@ def new_movement(): moved_by=current_user.id, reason=reason, notes=notes, - update_stock=True + update_stock=True, ) - + safe_commit() - - log_event('stock_movement_created', { - 'movement_id': movement.id, - 'movement_type': movement_type, - 'stock_item_id': stock_item_id, - 'warehouse_id': warehouse_id - }) - flash(_('Stock movement recorded successfully.'), 'success') - return redirect(url_for('inventory.list_movements')) - + + log_event( + "stock_movement_created", + { + "movement_id": movement.id, + "movement_type": movement_type, + "stock_item_id": stock_item_id, + "warehouse_id": warehouse_id, + }, + ) + flash(_("Stock movement recorded successfully."), "success") + return redirect(url_for("inventory.list_movements")) + except Exception as e: db.session.rollback() - flash(_('Error recording stock movement: %(error)s', error=str(e)), 'error') - + flash(_("Error recording stock movement: %(error)s", error=str(e)), "error") + # Get items and warehouses for form stock_items = StockItem.query.filter_by(is_active=True, is_trackable=True).order_by(StockItem.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - - return render_template('inventory/movements/form.html', - stock_items=stock_items, - warehouses=warehouses) + + return render_template("inventory/movements/form.html", stock_items=stock_items, warehouses=warehouses) # ==================== Stock Transfers ==================== -@inventory_bp.route('/inventory/transfers') + +@inventory_bp.route("/inventory/transfers") @login_required -@admin_or_permission_required('transfer_stock') +@admin_or_permission_required("transfer_stock") def list_transfers(): """List stock transfers between warehouses""" - query = StockMovement.query.filter_by(movement_type='transfer') - + query = StockMovement.query.filter_by(movement_type="transfer") + # Filter by date range if provided - date_from = request.args.get('date_from') - date_to = request.args.get('date_to') - + date_from = request.args.get("date_from") + date_to = request.args.get("date_to") + if date_from: try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d') + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") query = query.filter(StockMovement.moved_at >= date_from_obj) except ValueError: pass - + if date_to: try: - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d') + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") # Include the entire day date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59) query = query.filter(StockMovement.moved_at <= date_to_obj) except ValueError: pass - + # Group transfers by reference_id (transfers have paired movements) transfers = query.order_by(StockMovement.moved_at.desc()).limit(100).all() - + # Group by reference_id to show pairs together transfer_groups = {} for movement in transfers: - if movement.reference_type == 'transfer' and movement.reference_id: + if movement.reference_type == "transfer" and movement.reference_id: if movement.reference_id not in transfer_groups: transfer_groups[movement.reference_id] = [] transfer_groups[movement.reference_id].append(movement) - - return render_template('inventory/transfers/list.html', - transfer_groups=transfer_groups, - date_from=date_from, - date_to=date_to) + return render_template( + "inventory/transfers/list.html", transfer_groups=transfer_groups, date_from=date_from, date_to=date_to + ) -@inventory_bp.route('/inventory/transfers/new', methods=['GET', 'POST']) + +@inventory_bp.route("/inventory/transfers/new", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('transfer_stock') +@admin_or_permission_required("transfer_stock") def new_transfer(): """Create a stock transfer between warehouses""" - if request.method == 'POST': + if request.method == "POST": try: - stock_item_id = int(request.form.get('stock_item_id')) - from_warehouse_id = int(request.form.get('from_warehouse_id')) - to_warehouse_id = int(request.form.get('to_warehouse_id')) - quantity = Decimal(request.form.get('quantity')) - notes = request.form.get('notes', '').strip() or None - + stock_item_id = int(request.form.get("stock_item_id")) + from_warehouse_id = int(request.form.get("from_warehouse_id")) + to_warehouse_id = int(request.form.get("to_warehouse_id")) + quantity = Decimal(request.form.get("quantity")) + notes = request.form.get("notes", "").strip() or None + # Validate warehouses are different if from_warehouse_id == to_warehouse_id: - flash(_('Source and destination warehouses must be different.'), 'error') - stock_items = StockItem.query.filter_by(is_active=True, is_trackable=True).order_by(StockItem.name).all() + flash(_("Source and destination warehouses must be different."), "error") + stock_items = ( + StockItem.query.filter_by(is_active=True, is_trackable=True).order_by(StockItem.name).all() + ) warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - return render_template('inventory/transfers/form.html', - stock_items=stock_items, - warehouses=warehouses) - + return render_template("inventory/transfers/form.html", stock_items=stock_items, warehouses=warehouses) + # Check available stock in source warehouse source_stock = WarehouseStock.query.filter_by( - warehouse_id=from_warehouse_id, - stock_item_id=stock_item_id + warehouse_id=from_warehouse_id, stock_item_id=stock_item_id ).first() - + if not source_stock or source_stock.quantity_available < quantity: - flash(_('Insufficient stock available in source warehouse.'), 'error') - stock_items = StockItem.query.filter_by(is_active=True, is_trackable=True).order_by(StockItem.name).all() + flash(_("Insufficient stock available in source warehouse."), "error") + stock_items = ( + StockItem.query.filter_by(is_active=True, is_trackable=True).order_by(StockItem.name).all() + ) warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - return render_template('inventory/transfers/form.html', - stock_items=stock_items, - warehouses=warehouses) - + return render_template("inventory/transfers/form.html", stock_items=stock_items, warehouses=warehouses) + # Generate transfer reference ID (use timestamp-based ID) transfer_ref_id = int(datetime.now().timestamp() * 1000) - + # Create transfer reason stock_item = StockItem.query.get(stock_item_id) from_warehouse = Warehouse.query.get(from_warehouse_id) to_warehouse = Warehouse.query.get(to_warehouse_id) - reason = f'Transfer from {from_warehouse.code} to {to_warehouse.code}' - + reason = f"Transfer from {from_warehouse.code} to {to_warehouse.code}" + # Create negative movement (from source warehouse) out_movement, _ = StockMovement.record_movement( - movement_type='transfer', + movement_type="transfer", stock_item_id=stock_item_id, warehouse_id=from_warehouse_id, quantity=-quantity, # Negative for removal moved_by=current_user.id, - reference_type='transfer', + reference_type="transfer", reference_id=transfer_ref_id, reason=reason, notes=notes, - update_stock=True + update_stock=True, ) - + # Create positive movement (to destination warehouse) in_movement, _ = StockMovement.record_movement( - movement_type='transfer', + movement_type="transfer", stock_item_id=stock_item_id, warehouse_id=to_warehouse_id, quantity=quantity, # Positive for addition moved_by=current_user.id, - reference_type='transfer', + reference_type="transfer", reference_id=transfer_ref_id, reason=reason, notes=notes, - update_stock=True + update_stock=True, ) - + safe_commit() - - log_event('stock_transfer_created', { - 'transfer_ref_id': transfer_ref_id, - 'stock_item_id': stock_item_id, - 'from_warehouse_id': from_warehouse_id, - 'to_warehouse_id': to_warehouse_id, - 'quantity': float(quantity) - }) - flash(_('Stock transfer completed successfully.'), 'success') - return redirect(url_for('inventory.list_transfers')) - + + log_event( + "stock_transfer_created", + { + "transfer_ref_id": transfer_ref_id, + "stock_item_id": stock_item_id, + "from_warehouse_id": from_warehouse_id, + "to_warehouse_id": to_warehouse_id, + "quantity": float(quantity), + }, + ) + flash(_("Stock transfer completed successfully."), "success") + return redirect(url_for("inventory.list_transfers")) + except Exception as e: db.session.rollback() - flash(_('Error creating transfer: %(error)s', error=str(e)), 'error') - + flash(_("Error creating transfer: %(error)s", error=str(e)), "error") + # Get items and warehouses for form stock_items = StockItem.query.filter_by(is_active=True, is_trackable=True).order_by(StockItem.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - - return render_template('inventory/transfers/form.html', - stock_items=stock_items, - warehouses=warehouses) + + return render_template("inventory/transfers/form.html", stock_items=stock_items, warehouses=warehouses) # ==================== Stock Adjustments ==================== -@inventory_bp.route('/inventory/adjustments') + +@inventory_bp.route("/inventory/adjustments") @login_required -@admin_or_permission_required('view_stock_history') +@admin_or_permission_required("view_stock_history") def list_adjustments(): """List stock adjustments""" - query = StockMovement.query.filter_by(movement_type='adjustment') - + query = StockMovement.query.filter_by(movement_type="adjustment") + # Filter by warehouse, item, or date - warehouse_id = request.args.get('warehouse_id', type=int) - stock_item_id = request.args.get('stock_item_id', type=int) - date_from = request.args.get('date_from') - date_to = request.args.get('date_to') - + warehouse_id = request.args.get("warehouse_id", type=int) + stock_item_id = request.args.get("stock_item_id", type=int) + date_from = request.args.get("date_from") + date_to = request.args.get("date_to") + if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + if stock_item_id: query = query.filter_by(stock_item_id=stock_item_id) - + if date_from: try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d') + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") query = query.filter(StockMovement.moved_at >= date_from_obj) except ValueError: pass - + if date_to: try: - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d') + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59) query = query.filter(StockMovement.moved_at <= date_to_obj) except ValueError: pass - + adjustments = query.order_by(StockMovement.moved_at.desc()).limit(100).all() - + # Get warehouses and items for filters warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all() - - return render_template('inventory/adjustments/list.html', - adjustments=adjustments, - warehouses=warehouses, - stock_items=stock_items, - selected_warehouse_id=warehouse_id, - selected_stock_item_id=stock_item_id, - date_from=date_from, - date_to=date_to) - - -@inventory_bp.route('/inventory/adjustments/new', methods=['GET', 'POST']) + + return render_template( + "inventory/adjustments/list.html", + adjustments=adjustments, + warehouses=warehouses, + stock_items=stock_items, + selected_warehouse_id=warehouse_id, + selected_stock_item_id=stock_item_id, + date_from=date_from, + date_to=date_to, + ) + + +@inventory_bp.route("/inventory/adjustments/new", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_stock_movements') +@admin_or_permission_required("manage_stock_movements") def new_adjustment(): """Create a stock adjustment""" # Reuse the movements form but force movement_type to 'adjustment' - if request.method == 'POST': + if request.method == "POST": try: - stock_item_id = int(request.form.get('stock_item_id')) - warehouse_id = int(request.form.get('warehouse_id')) - quantity = Decimal(request.form.get('quantity')) - reason = request.form.get('reason', '').strip() or None - notes = request.form.get('notes', '').strip() or None - + stock_item_id = int(request.form.get("stock_item_id")) + warehouse_id = int(request.form.get("warehouse_id")) + quantity = Decimal(request.form.get("quantity")) + reason = request.form.get("reason", "").strip() or None + notes = request.form.get("notes", "").strip() or None + movement, updated_stock = StockMovement.record_movement( - movement_type='adjustment', + movement_type="adjustment", stock_item_id=stock_item_id, warehouse_id=warehouse_id, quantity=quantity, moved_by=current_user.id, - reason=reason or 'Stock adjustment', + reason=reason or "Stock adjustment", notes=notes, - update_stock=True + update_stock=True, ) - + safe_commit() - - log_event('stock_adjustment_created', { - 'adjustment_id': movement.id, - 'stock_item_id': stock_item_id, - 'warehouse_id': warehouse_id, - 'quantity': float(quantity) - }) - flash(_('Stock adjustment recorded successfully.'), 'success') - return redirect(url_for('inventory.list_adjustments')) - + + log_event( + "stock_adjustment_created", + { + "adjustment_id": movement.id, + "stock_item_id": stock_item_id, + "warehouse_id": warehouse_id, + "quantity": float(quantity), + }, + ) + flash(_("Stock adjustment recorded successfully."), "success") + return redirect(url_for("inventory.list_adjustments")) + except Exception as e: db.session.rollback() - flash(_('Error recording adjustment: %(error)s', error=str(e)), 'error') - + flash(_("Error recording adjustment: %(error)s", error=str(e)), "error") + # Get items and warehouses for form stock_items = StockItem.query.filter_by(is_active=True, is_trackable=True).order_by(StockItem.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - - return render_template('inventory/adjustments/form.html', - stock_items=stock_items, - warehouses=warehouses) + + return render_template("inventory/adjustments/form.html", stock_items=stock_items, warehouses=warehouses) # ==================== Stock Item History ==================== -@inventory_bp.route('/inventory/items//history') + +@inventory_bp.route("/inventory/items//history") @login_required -@admin_or_permission_required('view_stock_history') +@admin_or_permission_required("view_stock_history") def stock_item_history(item_id): """View movement history for a stock item""" item = StockItem.query.get_or_404(item_id) - + # Get filters - warehouse_id = request.args.get('warehouse_id', type=int) - movement_type = request.args.get('movement_type', '') - date_from = request.args.get('date_from') - date_to = request.args.get('date_to') - + warehouse_id = request.args.get("warehouse_id", type=int) + movement_type = request.args.get("movement_type", "") + date_from = request.args.get("date_from") + date_to = request.args.get("date_to") + query = StockMovement.query.filter_by(stock_item_id=item_id) - + if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + if movement_type: query = query.filter_by(movement_type=movement_type) - + if date_from: try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d') + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") query = query.filter(StockMovement.moved_at >= date_from_obj) except ValueError: pass - + if date_to: try: - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d') + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59) query = query.filter(StockMovement.moved_at <= date_to_obj) except ValueError: pass - + movements = query.order_by(StockMovement.moved_at.desc()).limit(200).all() - + # Get warehouses for filter warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - - return render_template('inventory/stock_items/history.html', - item=item, - movements=movements, - warehouses=warehouses, - selected_warehouse_id=warehouse_id, - selected_movement_type=movement_type, - date_from=date_from, - date_to=date_to) + + return render_template( + "inventory/stock_items/history.html", + item=item, + movements=movements, + warehouses=warehouses, + selected_warehouse_id=warehouse_id, + selected_movement_type=movement_type, + date_from=date_from, + date_to=date_to, + ) # ==================== Low Stock Alerts ==================== -@inventory_bp.route('/inventory/low-stock') + +@inventory_bp.route("/inventory/low-stock") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def low_stock_alerts(): """View low stock alerts""" items = StockItem.query.filter_by(is_active=True, is_trackable=True).all() - + low_stock_items = [] for item in items: if item.reorder_point: stock_levels = WarehouseStock.query.filter_by(stock_item_id=item.id).all() for stock in stock_levels: if stock.quantity_on_hand < item.reorder_point: - low_stock_items.append({ - 'item': item, - 'warehouse': stock.warehouse, - 'quantity_on_hand': stock.quantity_on_hand, - 'reorder_point': item.reorder_point, - 'reorder_quantity': item.reorder_quantity or 0, - 'shortfall': item.reorder_point - stock.quantity_on_hand - }) - - return render_template('inventory/low_stock/list.html', low_stock_items=low_stock_items) + low_stock_items.append( + { + "item": item, + "warehouse": stock.warehouse, + "quantity_on_hand": stock.quantity_on_hand, + "reorder_point": item.reorder_point, + "reorder_quantity": item.reorder_quantity or 0, + "shortfall": item.reorder_point - stock.quantity_on_hand, + } + ) + + return render_template("inventory/low_stock/list.html", low_stock_items=low_stock_items) # ==================== Stock Reservations ==================== -@inventory_bp.route('/inventory/reservations') + +@inventory_bp.route("/inventory/reservations") @login_required -@admin_or_permission_required('view_stock_history') +@admin_or_permission_required("view_stock_history") def list_reservations(): """List stock reservations""" - status = request.args.get('status', 'reserved') - + status = request.args.get("status", "reserved") + query = StockReservation.query - - if status != 'all': + + if status != "all": query = query.filter_by(status=status) - + reservations = query.order_by(StockReservation.reserved_at.desc()).all() - - return render_template('inventory/reservations/list.html', - reservations=reservations, - status=status) + + return render_template("inventory/reservations/list.html", reservations=reservations, status=status) -@inventory_bp.route('/inventory/reservations//fulfill', methods=['POST']) +@inventory_bp.route("/inventory/reservations//fulfill", methods=["POST"]) @login_required -@admin_or_permission_required('manage_stock_reservations') +@admin_or_permission_required("manage_stock_reservations") def fulfill_reservation(reservation_id): """Fulfill a stock reservation""" reservation = StockReservation.query.get_or_404(reservation_id) - + try: reservation.fulfill() safe_commit() - - log_event('stock_reservation_fulfilled', {'reservation_id': reservation_id}) - flash(_('Reservation fulfilled successfully.'), 'success') + + log_event("stock_reservation_fulfilled", {"reservation_id": reservation_id}) + flash(_("Reservation fulfilled successfully."), "success") except Exception as e: db.session.rollback() - flash(_('Error fulfilling reservation: %(error)s', error=str(e)), 'error') - - return redirect(url_for('inventory.list_reservations')) + flash(_("Error fulfilling reservation: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.list_reservations")) -@inventory_bp.route('/inventory/reservations//cancel', methods=['POST']) +@inventory_bp.route("/inventory/reservations//cancel", methods=["POST"]) @login_required -@admin_or_permission_required('manage_stock_reservations') +@admin_or_permission_required("manage_stock_reservations") def cancel_reservation(reservation_id): """Cancel a stock reservation""" reservation = StockReservation.query.get_or_404(reservation_id) - + try: reservation.cancel() safe_commit() - - log_event('stock_reservation_cancelled', {'reservation_id': reservation_id}) - flash(_('Reservation cancelled successfully.'), 'success') + + log_event("stock_reservation_cancelled", {"reservation_id": reservation_id}) + flash(_("Reservation cancelled successfully."), "success") except Exception as e: db.session.rollback() - flash(_('Error cancelling reservation: %(error)s', error=str(e)), 'error') - - return redirect(url_for('inventory.list_reservations')) + flash(_("Error cancelling reservation: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.list_reservations")) # ==================== Suppliers ==================== -@inventory_bp.route('/inventory/suppliers') + +@inventory_bp.route("/inventory/suppliers") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def list_suppliers(): """List all suppliers""" - search = request.args.get('search', '').strip() - active_only = request.args.get('active', 'true').lower() == 'true' - + search = request.args.get("search", "").strip() + active_only = request.args.get("active", "true").lower() == "true" + query = Supplier.query - + if active_only: query = query.filter_by(is_active=True) - + if search: like = f"%{search}%" - query = query.filter( - or_( - Supplier.code.ilike(like), - Supplier.name.ilike(like), - Supplier.email.ilike(like) - ) - ) - + query = query.filter(or_(Supplier.code.ilike(like), Supplier.name.ilike(like), Supplier.email.ilike(like))) + suppliers = query.order_by(Supplier.name).all() - - return render_template('inventory/suppliers/list.html', - suppliers=suppliers, - search=search, - active_only=active_only) + + return render_template("inventory/suppliers/list.html", suppliers=suppliers, search=search, active_only=active_only) -@inventory_bp.route('/inventory/suppliers/new', methods=['GET', 'POST']) +@inventory_bp.route("/inventory/suppliers/new", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def new_supplier(): """Create a new supplier""" - if request.method == 'POST': + if request.method == "POST": try: supplier = Supplier( - code=request.form.get('code', '').strip(), - name=request.form.get('name', '').strip(), + code=request.form.get("code", "").strip(), + name=request.form.get("name", "").strip(), created_by=current_user.id, - description=request.form.get('description', '').strip() or None, - contact_person=request.form.get('contact_person', '').strip() or None, - email=request.form.get('email', '').strip() or None, - phone=request.form.get('phone', '').strip() or None, - address=request.form.get('address', '').strip() or None, - website=request.form.get('website', '').strip() or None, - tax_id=request.form.get('tax_id', '').strip() or None, - payment_terms=request.form.get('payment_terms', '').strip() or None, - currency_code=request.form.get('currency_code', 'EUR'), - is_active=request.form.get('is_active') == 'on', - notes=request.form.get('notes', '').strip() or None + description=request.form.get("description", "").strip() or None, + contact_person=request.form.get("contact_person", "").strip() or None, + email=request.form.get("email", "").strip() or None, + phone=request.form.get("phone", "").strip() or None, + address=request.form.get("address", "").strip() or None, + website=request.form.get("website", "").strip() or None, + tax_id=request.form.get("tax_id", "").strip() or None, + payment_terms=request.form.get("payment_terms", "").strip() or None, + currency_code=request.form.get("currency_code", "EUR"), + is_active=request.form.get("is_active") == "on", + notes=request.form.get("notes", "").strip() or None, ) - + db.session.add(supplier) safe_commit() - - log_event('supplier_created', {'supplier_id': supplier.id, 'supplier_code': supplier.code}) - flash(_('Supplier created successfully.'), 'success') - return redirect(url_for('inventory.view_supplier', supplier_id=supplier.id)) - + + log_event("supplier_created", {"supplier_id": supplier.id, "supplier_code": supplier.code}) + flash(_("Supplier created successfully."), "success") + return redirect(url_for("inventory.view_supplier", supplier_id=supplier.id)) + except Exception as e: db.session.rollback() - flash(_('Error creating supplier: %(error)s', error=str(e)), 'error') - - return render_template('inventory/suppliers/form.html', supplier=None) + flash(_("Error creating supplier: %(error)s", error=str(e)), "error") + return render_template("inventory/suppliers/form.html", supplier=None) -@inventory_bp.route('/inventory/suppliers/') + +@inventory_bp.route("/inventory/suppliers/") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def view_supplier(supplier_id): """View supplier details""" supplier = Supplier.query.get_or_404(supplier_id) - + # Get stock items from this supplier from sqlalchemy.orm import joinedload - supplier_items = SupplierStockItem.query.options( - joinedload(SupplierStockItem.stock_item) - ).filter_by( - supplier_id=supplier_id, - is_active=True - ).all() - + + supplier_items = ( + SupplierStockItem.query.options(joinedload(SupplierStockItem.stock_item)) + .filter_by(supplier_id=supplier_id, is_active=True) + .all() + ) + # Sort by preferred, then by stock item name supplier_items = sorted(supplier_items, key=lambda x: (not x.is_preferred, x.stock_item.name)) - - return render_template('inventory/suppliers/view.html', - supplier=supplier, - supplier_items=supplier_items) + return render_template("inventory/suppliers/view.html", supplier=supplier, supplier_items=supplier_items) -@inventory_bp.route('/inventory/suppliers//edit', methods=['GET', 'POST']) + +@inventory_bp.route("/inventory/suppliers//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def edit_supplier(supplier_id): """Edit supplier""" supplier = Supplier.query.get_or_404(supplier_id) - - if request.method == 'POST': + + if request.method == "POST": try: - new_code = request.form.get('code', '').strip().upper() - + new_code = request.form.get("code", "").strip().upper() + # Check if code is being changed and if new code exists if new_code != supplier.code: existing = Supplier.query.filter_by(code=new_code).first() if existing: - flash(_('Supplier code already exists. Please use a different code.'), 'error') - return render_template('inventory/suppliers/form.html', supplier=supplier) - + flash(_("Supplier code already exists. Please use a different code."), "error") + return render_template("inventory/suppliers/form.html", supplier=supplier) + supplier.code = new_code - supplier.name = request.form.get('name', '').strip() - supplier.description = request.form.get('description', '').strip() or None - supplier.contact_person = request.form.get('contact_person', '').strip() or None - supplier.email = request.form.get('email', '').strip() or None - supplier.phone = request.form.get('phone', '').strip() or None - supplier.address = request.form.get('address', '').strip() or None - supplier.website = request.form.get('website', '').strip() or None - supplier.tax_id = request.form.get('tax_id', '').strip() or None - supplier.payment_terms = request.form.get('payment_terms', '').strip() or None - supplier.currency_code = request.form.get('currency_code', 'EUR') - supplier.is_active = request.form.get('is_active') == 'on' - supplier.notes = request.form.get('notes', '').strip() or None + supplier.name = request.form.get("name", "").strip() + supplier.description = request.form.get("description", "").strip() or None + supplier.contact_person = request.form.get("contact_person", "").strip() or None + supplier.email = request.form.get("email", "").strip() or None + supplier.phone = request.form.get("phone", "").strip() or None + supplier.address = request.form.get("address", "").strip() or None + supplier.website = request.form.get("website", "").strip() or None + supplier.tax_id = request.form.get("tax_id", "").strip() or None + supplier.payment_terms = request.form.get("payment_terms", "").strip() or None + supplier.currency_code = request.form.get("currency_code", "EUR") + supplier.is_active = request.form.get("is_active") == "on" + supplier.notes = request.form.get("notes", "").strip() or None supplier.updated_at = datetime.utcnow() - + safe_commit() - - log_event('supplier_updated', {'supplier_id': supplier.id}) - flash(_('Supplier updated successfully.'), 'success') - return redirect(url_for('inventory.view_supplier', supplier_id=supplier.id)) - + + log_event("supplier_updated", {"supplier_id": supplier.id}) + flash(_("Supplier updated successfully."), "success") + return redirect(url_for("inventory.view_supplier", supplier_id=supplier.id)) + except Exception as e: db.session.rollback() - flash(_('Error updating supplier: %(error)s', error=str(e)), 'error') - - return render_template('inventory/suppliers/form.html', supplier=supplier) + flash(_("Error updating supplier: %(error)s", error=str(e)), "error") + + return render_template("inventory/suppliers/form.html", supplier=supplier) -@inventory_bp.route('/inventory/suppliers//delete', methods=['POST']) +@inventory_bp.route("/inventory/suppliers//delete", methods=["POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def delete_supplier(supplier_id): """Delete supplier""" supplier = Supplier.query.get_or_404(supplier_id) - + # Check if supplier has associated stock items item_count = SupplierStockItem.query.filter_by(supplier_id=supplier_id).count() - + if item_count > 0: - flash(_('Cannot delete supplier with associated stock items. Remove items first.'), 'error') - return redirect(url_for('inventory.view_supplier', supplier_id=supplier_id)) - + flash(_("Cannot delete supplier with associated stock items. Remove items first."), "error") + return redirect(url_for("inventory.view_supplier", supplier_id=supplier_id)) + try: code = supplier.code db.session.delete(supplier) safe_commit() - - log_event('supplier_deleted', {'supplier_code': code}) - flash(_('Supplier deleted successfully.'), 'success') + + log_event("supplier_deleted", {"supplier_code": code}) + flash(_("Supplier deleted successfully."), "success") except Exception as e: db.session.rollback() - flash(_('Error deleting supplier: %(error)s', error=str(e)), 'error') - - return redirect(url_for('inventory.list_suppliers')) + flash(_("Error deleting supplier: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.list_suppliers")) # ==================== Purchase Orders ==================== -@inventory_bp.route('/inventory/purchase-orders') + +@inventory_bp.route("/inventory/purchase-orders") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def list_purchase_orders(): """List all purchase orders""" - status = request.args.get('status', '') - supplier_id = request.args.get('supplier_id', type=int) - + status = request.args.get("status", "") + supplier_id = request.args.get("supplier_id", type=int) + query = PurchaseOrder.query - + if status: query = query.filter_by(status=status) - + if supplier_id: query = query.filter_by(supplier_id=supplier_id) - + purchase_orders = query.order_by(PurchaseOrder.order_date.desc(), PurchaseOrder.po_number.desc()).limit(100).all() - + # Get suppliers for filter suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all() - - return render_template('inventory/purchase_orders/list.html', - purchase_orders=purchase_orders, - suppliers=suppliers, - selected_status=status, - selected_supplier_id=supplier_id) + + return render_template( + "inventory/purchase_orders/list.html", + purchase_orders=purchase_orders, + suppliers=suppliers, + selected_status=status, + selected_supplier_id=supplier_id, + ) -@inventory_bp.route('/inventory/purchase-orders/new', methods=['GET', 'POST']) +@inventory_bp.route("/inventory/purchase-orders/new", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def new_purchase_order(): """Create a new purchase order""" - if request.method == 'POST': + if request.method == "POST": try: # Generate PO number last_po = PurchaseOrder.query.order_by(PurchaseOrder.id.desc()).first() next_id = (last_po.id + 1) if last_po else 1 po_number = f"PO-{datetime.now().strftime('%Y%m%d')}-{next_id:04d}" - + purchase_order = PurchaseOrder( po_number=po_number, - supplier_id=int(request.form.get('supplier_id')), - order_date=datetime.strptime(request.form.get('order_date'), '%Y-%m-%d').date(), + supplier_id=int(request.form.get("supplier_id")), + order_date=datetime.strptime(request.form.get("order_date"), "%Y-%m-%d").date(), created_by=current_user.id, - expected_delivery_date=datetime.strptime(request.form.get('expected_delivery_date'), '%Y-%m-%d').date() if request.form.get('expected_delivery_date') else None, - notes=request.form.get('notes', '').strip() or None, - internal_notes=request.form.get('internal_notes', '').strip() or None, - currency_code=request.form.get('currency_code', 'EUR') + expected_delivery_date=( + datetime.strptime(request.form.get("expected_delivery_date"), "%Y-%m-%d").date() + if request.form.get("expected_delivery_date") + else None + ), + notes=request.form.get("notes", "").strip() or None, + internal_notes=request.form.get("internal_notes", "").strip() or None, + currency_code=request.form.get("currency_code", "EUR"), ) - + db.session.add(purchase_order) db.session.flush() - + # Handle items - item_descriptions = request.form.getlist('item_description[]') - item_stock_ids = request.form.getlist('item_stock_item_id[]') - item_supplier_stock_ids = request.form.getlist('item_supplier_stock_item_id[]') - item_supplier_skus = request.form.getlist('item_supplier_sku[]') - item_quantities = request.form.getlist('item_quantity[]') - item_unit_costs = request.form.getlist('item_unit_cost[]') - item_warehouse_ids = request.form.getlist('item_warehouse_id[]') - + item_descriptions = request.form.getlist("item_description[]") + item_stock_ids = request.form.getlist("item_stock_item_id[]") + item_supplier_stock_ids = request.form.getlist("item_supplier_stock_item_id[]") + item_supplier_skus = request.form.getlist("item_supplier_sku[]") + item_quantities = request.form.getlist("item_quantity[]") + item_unit_costs = request.form.getlist("item_unit_cost[]") + item_warehouse_ids = request.form.getlist("item_warehouse_id[]") + for i, desc in enumerate(item_descriptions): if desc.strip(): try: - stock_item_id = int(item_stock_ids[i]) if i < len(item_stock_ids) and item_stock_ids[i] else None - supplier_stock_item_id = int(item_supplier_stock_ids[i]) if i < len(item_supplier_stock_ids) and item_supplier_stock_ids[i] else None - warehouse_id = int(item_warehouse_ids[i]) if i < len(item_warehouse_ids) and item_warehouse_ids[i] else None - + stock_item_id = ( + int(item_stock_ids[i]) if i < len(item_stock_ids) and item_stock_ids[i] else None + ) + supplier_stock_item_id = ( + int(item_supplier_stock_ids[i]) + if i < len(item_supplier_stock_ids) and item_supplier_stock_ids[i] + else None + ) + warehouse_id = ( + int(item_warehouse_ids[i]) + if i < len(item_warehouse_ids) and item_warehouse_ids[i] + else None + ) + item = PurchaseOrderItem( purchase_order_id=purchase_order.id, description=desc.strip(), - quantity_ordered=Decimal(item_quantities[i]) if i < len(item_quantities) and item_quantities[i] else Decimal('1'), - unit_cost=Decimal(item_unit_costs[i]) if i < len(item_unit_costs) and item_unit_costs[i] else Decimal('0'), + quantity_ordered=( + Decimal(item_quantities[i]) + if i < len(item_quantities) and item_quantities[i] + else Decimal("1") + ), + unit_cost=( + Decimal(item_unit_costs[i]) + if i < len(item_unit_costs) and item_unit_costs[i] + else Decimal("0") + ), stock_item_id=stock_item_id, supplier_stock_item_id=supplier_stock_item_id, - supplier_sku=item_supplier_skus[i].strip() if i < len(item_supplier_skus) and item_supplier_skus[i] else None, + supplier_sku=( + item_supplier_skus[i].strip() + if i < len(item_supplier_skus) and item_supplier_skus[i] + else None + ), warehouse_id=warehouse_id, - currency_code=purchase_order.currency_code + currency_code=purchase_order.currency_code, ) db.session.add(item) except (ValueError, InvalidOperation): pass - + purchase_order.calculate_totals() safe_commit() - - log_event('purchase_order_created', {'purchase_order_id': purchase_order.id, 'po_number': purchase_order.po_number}) - flash(_('Purchase order created successfully.'), 'success') - return redirect(url_for('inventory.view_purchase_order', po_id=purchase_order.id)) - + + log_event( + "purchase_order_created", + {"purchase_order_id": purchase_order.id, "po_number": purchase_order.po_number}, + ) + flash(_("Purchase order created successfully."), "success") + return redirect(url_for("inventory.view_purchase_order", po_id=purchase_order.id)) + except Exception as e: db.session.rollback() - flash(_('Error creating purchase order: %(error)s', error=str(e)), 'error') - + flash(_("Error creating purchase order: %(error)s", error=str(e)), "error") + # Get suppliers and warehouses for form suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all() - - return render_template('inventory/purchase_orders/form.html', - purchase_order=None, - suppliers=suppliers, - warehouses=warehouses, - stock_items=stock_items) + return render_template( + "inventory/purchase_orders/form.html", + purchase_order=None, + suppliers=suppliers, + warehouses=warehouses, + stock_items=stock_items, + ) -@inventory_bp.route('/inventory/purchase-orders/') + +@inventory_bp.route("/inventory/purchase-orders/") @login_required -@admin_or_permission_required('view_inventory') +@admin_or_permission_required("view_inventory") def view_purchase_order(po_id): """View purchase order details""" purchase_order = PurchaseOrder.query.get_or_404(po_id) - - return render_template('inventory/purchase_orders/view.html', - purchase_order=purchase_order) + + return render_template("inventory/purchase_orders/view.html", purchase_order=purchase_order) -@inventory_bp.route('/inventory/purchase-orders//edit', methods=['GET', 'POST']) +@inventory_bp.route("/inventory/purchase-orders//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def edit_purchase_order(po_id): """Edit purchase order""" purchase_order = PurchaseOrder.query.get_or_404(po_id) - - if purchase_order.status == 'received': - flash(_('Cannot edit a purchase order that has been received.'), 'error') - return redirect(url_for('inventory.view_purchase_order', po_id=po_id)) - - if request.method == 'POST': + + if purchase_order.status == "received": + flash(_("Cannot edit a purchase order that has been received."), "error") + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) + + if request.method == "POST": try: - purchase_order.order_date = datetime.strptime(request.form.get('order_date'), '%Y-%m-%d').date() - purchase_order.expected_delivery_date = datetime.strptime(request.form.get('expected_delivery_date'), '%Y-%m-%d').date() if request.form.get('expected_delivery_date') else None - purchase_order.notes = request.form.get('notes', '').strip() or None - purchase_order.internal_notes = request.form.get('internal_notes', '').strip() or None - purchase_order.currency_code = request.form.get('currency_code', 'EUR') - + purchase_order.order_date = datetime.strptime(request.form.get("order_date"), "%Y-%m-%d").date() + purchase_order.expected_delivery_date = ( + datetime.strptime(request.form.get("expected_delivery_date"), "%Y-%m-%d").date() + if request.form.get("expected_delivery_date") + else None + ) + purchase_order.notes = request.form.get("notes", "").strip() or None + purchase_order.internal_notes = request.form.get("internal_notes", "").strip() or None + purchase_order.currency_code = request.form.get("currency_code", "EUR") + # Handle items - remove existing and recreate PurchaseOrderItem.query.filter_by(purchase_order_id=purchase_order.id).delete() - - item_descriptions = request.form.getlist('item_description[]') - item_stock_ids = request.form.getlist('item_stock_item_id[]') - item_supplier_stock_ids = request.form.getlist('item_supplier_stock_item_id[]') - item_supplier_skus = request.form.getlist('item_supplier_sku[]') - item_quantities = request.form.getlist('item_quantity[]') - item_unit_costs = request.form.getlist('item_unit_cost[]') - item_warehouse_ids = request.form.getlist('item_warehouse_id[]') - + + item_descriptions = request.form.getlist("item_description[]") + item_stock_ids = request.form.getlist("item_stock_item_id[]") + item_supplier_stock_ids = request.form.getlist("item_supplier_stock_item_id[]") + item_supplier_skus = request.form.getlist("item_supplier_sku[]") + item_quantities = request.form.getlist("item_quantity[]") + item_unit_costs = request.form.getlist("item_unit_cost[]") + item_warehouse_ids = request.form.getlist("item_warehouse_id[]") + for i, desc in enumerate(item_descriptions): if desc.strip(): try: - stock_item_id = int(item_stock_ids[i]) if i < len(item_stock_ids) and item_stock_ids[i] else None - supplier_stock_item_id = int(item_supplier_stock_ids[i]) if i < len(item_supplier_stock_ids) and item_supplier_stock_ids[i] else None - warehouse_id = int(item_warehouse_ids[i]) if i < len(item_warehouse_ids) and item_warehouse_ids[i] else None - + stock_item_id = ( + int(item_stock_ids[i]) if i < len(item_stock_ids) and item_stock_ids[i] else None + ) + supplier_stock_item_id = ( + int(item_supplier_stock_ids[i]) + if i < len(item_supplier_stock_ids) and item_supplier_stock_ids[i] + else None + ) + warehouse_id = ( + int(item_warehouse_ids[i]) + if i < len(item_warehouse_ids) and item_warehouse_ids[i] + else None + ) + item = PurchaseOrderItem( purchase_order_id=purchase_order.id, description=desc.strip(), - quantity_ordered=Decimal(item_quantities[i]) if i < len(item_quantities) and item_quantities[i] else Decimal('1'), - unit_cost=Decimal(item_unit_costs[i]) if i < len(item_unit_costs) and item_unit_costs[i] else Decimal('0'), + quantity_ordered=( + Decimal(item_quantities[i]) + if i < len(item_quantities) and item_quantities[i] + else Decimal("1") + ), + unit_cost=( + Decimal(item_unit_costs[i]) + if i < len(item_unit_costs) and item_unit_costs[i] + else Decimal("0") + ), stock_item_id=stock_item_id, supplier_stock_item_id=supplier_stock_item_id, - supplier_sku=item_supplier_skus[i].strip() if i < len(item_supplier_skus) and item_supplier_skus[i] else None, + supplier_sku=( + item_supplier_skus[i].strip() + if i < len(item_supplier_skus) and item_supplier_skus[i] + else None + ), warehouse_id=warehouse_id, - currency_code=purchase_order.currency_code + currency_code=purchase_order.currency_code, ) db.session.add(item) except (ValueError, InvalidOperation): pass - + purchase_order.calculate_totals() safe_commit() - - log_event('purchase_order_updated', {'purchase_order_id': po_id}) - flash(_('Purchase order updated successfully.'), 'success') - return redirect(url_for('inventory.view_purchase_order', po_id=po_id)) - + + log_event("purchase_order_updated", {"purchase_order_id": po_id}) + flash(_("Purchase order updated successfully."), "success") + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) + except Exception as e: db.session.rollback() - flash(_('Error updating purchase order: %(error)s', error=str(e)), 'error') - + flash(_("Error updating purchase order: %(error)s", error=str(e)), "error") + suppliers = Supplier.query.filter_by(is_active=True).order_by(Supplier.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all() - - return render_template('inventory/purchase_orders/form.html', - purchase_order=purchase_order, - suppliers=suppliers, - warehouses=warehouses, - stock_items=stock_items) + + return render_template( + "inventory/purchase_orders/form.html", + purchase_order=purchase_order, + suppliers=suppliers, + warehouses=warehouses, + stock_items=stock_items, + ) -@inventory_bp.route('/inventory/purchase-orders//send', methods=['POST']) +@inventory_bp.route("/inventory/purchase-orders//send", methods=["POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def send_purchase_order(po_id): """Mark purchase order as sent to supplier""" purchase_order = PurchaseOrder.query.get_or_404(po_id) - - if request.method == 'POST': + + if request.method == "POST": try: purchase_order.mark_as_sent() safe_commit() - - log_event('purchase_order_sent', {'purchase_order_id': po_id}) - flash(_('Purchase order marked as sent.'), 'success') + + log_event("purchase_order_sent", {"purchase_order_id": po_id}) + flash(_("Purchase order marked as sent."), "success") except Exception as e: db.session.rollback() - flash(_('Error sending purchase order: %(error)s', error=str(e)), 'error') - - return redirect(url_for('inventory.view_purchase_order', po_id=po_id)) + flash(_("Error sending purchase order: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) -@inventory_bp.route('/inventory/purchase-orders//cancel', methods=['POST']) +@inventory_bp.route("/inventory/purchase-orders//cancel", methods=["POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def cancel_purchase_order(po_id): """Cancel purchase order""" purchase_order = PurchaseOrder.query.get_or_404(po_id) - - if request.method == 'POST': + + if request.method == "POST": try: purchase_order.cancel() safe_commit() - - log_event('purchase_order_cancelled', {'purchase_order_id': po_id}) - flash(_('Purchase order cancelled successfully.'), 'success') + + log_event("purchase_order_cancelled", {"purchase_order_id": po_id}) + flash(_("Purchase order cancelled successfully."), "success") except Exception as e: db.session.rollback() - flash(_('Error cancelling purchase order: %(error)s', error=str(e)), 'error') - - return redirect(url_for('inventory.view_purchase_order', po_id=po_id)) + flash(_("Error cancelling purchase order: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) -@inventory_bp.route('/inventory/purchase-orders//delete', methods=['POST']) +@inventory_bp.route("/inventory/purchase-orders//delete", methods=["POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def delete_purchase_order(po_id): """Delete purchase order""" purchase_order = PurchaseOrder.query.get_or_404(po_id) - - if request.method == 'POST': + + if request.method == "POST": try: - if purchase_order.status == 'received': - flash(_('Cannot delete a purchase order that has been received. Cancel it instead.'), 'error') - return redirect(url_for('inventory.view_purchase_order', po_id=po_id)) - + if purchase_order.status == "received": + flash(_("Cannot delete a purchase order that has been received. Cancel it instead."), "error") + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) + po_number = purchase_order.po_number db.session.delete(purchase_order) safe_commit() - - log_event('purchase_order_deleted', {'po_number': po_number}) - flash(_('Purchase order deleted successfully.'), 'success') - return redirect(url_for('inventory.list_purchase_orders')) + + log_event("purchase_order_deleted", {"po_number": po_number}) + flash(_("Purchase order deleted successfully."), "success") + return redirect(url_for("inventory.list_purchase_orders")) except Exception as e: db.session.rollback() - flash(_('Error deleting purchase order: %(error)s', error=str(e)), 'error') - - return redirect(url_for('inventory.view_purchase_order', po_id=po_id)) + flash(_("Error deleting purchase order: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) -@inventory_bp.route('/inventory/purchase-orders//receive', methods=['POST']) +@inventory_bp.route("/inventory/purchase-orders//receive", methods=["POST"]) @login_required -@admin_or_permission_required('manage_inventory') +@admin_or_permission_required("manage_inventory") def receive_purchase_order(po_id): """Mark purchase order as received and update stock""" purchase_order = PurchaseOrder.query.get_or_404(po_id) - - if request.method == 'POST': + + if request.method == "POST": try: # Update received quantities - item_ids = request.form.getlist('item_id[]') - received_quantities = request.form.getlist('quantity_received[]') - + item_ids = request.form.getlist("item_id[]") + received_quantities = request.form.getlist("quantity_received[]") + for i, item_id in enumerate(item_ids): if item_id and received_quantities[i]: item = PurchaseOrderItem.query.get(int(item_id)) if item and item.purchase_order_id == purchase_order.id: item.quantity_received = Decimal(received_quantities[i]) item.updated_at = datetime.utcnow() - + # Mark as received (this will create stock movements) - received_date_str = request.form.get('received_date', '').strip() - received_date = datetime.strptime(received_date_str, '%Y-%m-%d').date() if received_date_str else datetime.utcnow().date() + received_date_str = request.form.get("received_date", "").strip() + received_date = ( + datetime.strptime(received_date_str, "%Y-%m-%d").date() + if received_date_str + else datetime.utcnow().date() + ) purchase_order.mark_as_received(received_date) - + safe_commit() - - log_event('purchase_order_received', {'purchase_order_id': po_id}) - flash(_('Purchase order marked as received and stock updated.'), 'success') + + log_event("purchase_order_received", {"purchase_order_id": po_id}) + flash(_("Purchase order marked as received and stock updated."), "success") except Exception as e: db.session.rollback() - flash(_('Error receiving purchase order: %(error)s', error=str(e)), 'error') - - return redirect(url_for('inventory.view_purchase_order', po_id=po_id)) + flash(_("Error receiving purchase order: %(error)s", error=str(e)), "error") + + return redirect(url_for("inventory.view_purchase_order", po_id=po_id)) # ==================== Inventory Reports ==================== -@inventory_bp.route('/inventory/reports') + +@inventory_bp.route("/inventory/reports") @login_required -@admin_or_permission_required('view_inventory_reports') +@admin_or_permission_required("view_inventory_reports") def reports_dashboard(): """Inventory reports dashboard""" total_items = StockItem.query.filter_by(is_active=True).count() total_warehouses = Warehouse.query.filter_by(is_active=True).count() - - total_value = db.session.query( - func.sum(WarehouseStock.quantity_on_hand * StockItem.default_cost) - ).join(StockItem).filter(StockItem.default_cost.isnot(None)).scalar() or 0 - + + total_value = ( + db.session.query(func.sum(WarehouseStock.quantity_on_hand * StockItem.default_cost)) + .join(StockItem) + .filter(StockItem.default_cost.isnot(None)) + .scalar() + or 0 + ) + low_stock_count = 0 items_with_reorder = StockItem.query.filter( - StockItem.is_active == True, - StockItem.is_trackable == True, - StockItem.reorder_point.isnot(None) + StockItem.is_active == True, StockItem.is_trackable == True, StockItem.reorder_point.isnot(None) ).all() - + for item in items_with_reorder: stock_levels = WarehouseStock.query.filter_by(stock_item_id=item.id).all() for stock in stock_levels: if stock.quantity_on_hand < item.reorder_point: low_stock_count += 1 break - - return render_template('inventory/reports/dashboard.html', - total_items=total_items, - total_warehouses=total_warehouses, - total_value=float(total_value), - low_stock_count=low_stock_count) + + return render_template( + "inventory/reports/dashboard.html", + total_items=total_items, + total_warehouses=total_warehouses, + total_value=float(total_value), + low_stock_count=low_stock_count, + ) -@inventory_bp.route('/inventory/reports/valuation') +@inventory_bp.route("/inventory/reports/valuation") @login_required -@admin_or_permission_required('view_inventory_reports') +@admin_or_permission_required("view_inventory_reports") def reports_valuation(): """Stock valuation report""" from app.services.inventory_report_service import InventoryReportService - - warehouse_id = request.args.get('warehouse_id', type=int) - category = request.args.get('category', '') - currency_code = request.args.get('currency_code', '') - + + warehouse_id = request.args.get("warehouse_id", type=int) + category = request.args.get("category", "") + currency_code = request.args.get("currency_code", "") + service = InventoryReportService() valuation_data = service.get_stock_valuation( warehouse_id=warehouse_id, category=category if category else None, - currency_code=currency_code if currency_code else None + currency_code=currency_code if currency_code else None, ) - + warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - categories = db.session.query(StockItem.category).distinct().filter( - StockItem.category.isnot(None) - ).order_by(StockItem.category).all() + categories = ( + db.session.query(StockItem.category) + .distinct() + .filter(StockItem.category.isnot(None)) + .order_by(StockItem.category) + .all() + ) categories = [cat[0] for cat in categories] - - currencies = db.session.query(StockItem.currency_code).distinct().filter( - StockItem.currency_code.isnot(None) - ).order_by(StockItem.currency_code).all() + + currencies = ( + db.session.query(StockItem.currency_code) + .distinct() + .filter(StockItem.currency_code.isnot(None)) + .order_by(StockItem.currency_code) + .all() + ) currencies = [curr[0] for curr in currencies] - + # Extract items_with_value from valuation_data for template compatibility items_with_value = [] - for item_detail in valuation_data.get('item_details', []): + for item_detail in valuation_data.get("item_details", []): # Get the actual stock and item objects for the template stock = WarehouseStock.query.filter_by( - stock_item_id=item_detail['item_id'], - warehouse_id=item_detail['warehouse_id'] + stock_item_id=item_detail["item_id"], warehouse_id=item_detail["warehouse_id"] ).first() if stock: - items_with_value.append({ - 'stock': stock, - 'value': item_detail['value'] - }) - - return render_template('inventory/reports/valuation.html', - valuation_data=valuation_data, - items_with_value=items_with_value, - total_value=valuation_data.get('total_value', 0), - warehouses=warehouses, - categories=categories, - currencies=currencies, - selected_warehouse_id=warehouse_id, - selected_category=category, - selected_currency=currency_code) - - -@inventory_bp.route('/inventory/reports/movement-history') + items_with_value.append({"stock": stock, "value": item_detail["value"]}) + + return render_template( + "inventory/reports/valuation.html", + valuation_data=valuation_data, + items_with_value=items_with_value, + total_value=valuation_data.get("total_value", 0), + warehouses=warehouses, + categories=categories, + currencies=currencies, + selected_warehouse_id=warehouse_id, + selected_category=category, + selected_currency=currency_code, + ) + + +@inventory_bp.route("/inventory/reports/movement-history") @login_required -@admin_or_permission_required('view_inventory_reports') +@admin_or_permission_required("view_inventory_reports") def reports_movement_history(): """Movement history report""" - date_from = request.args.get('date_from') - date_to = request.args.get('date_to') - warehouse_id = request.args.get('warehouse_id', type=int) - stock_item_id = request.args.get('stock_item_id', type=int) - movement_type = request.args.get('movement_type', '') - + date_from = request.args.get("date_from") + date_to = request.args.get("date_to") + warehouse_id = request.args.get("warehouse_id", type=int) + stock_item_id = request.args.get("stock_item_id", type=int) + movement_type = request.args.get("movement_type", "") + query = StockMovement.query - + if date_from: try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d') + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") query = query.filter(StockMovement.moved_at >= date_from_obj) except ValueError: pass - + if date_to: try: - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d') + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59) query = query.filter(StockMovement.moved_at <= date_to_obj) except ValueError: pass - + if warehouse_id: query = query.filter_by(warehouse_id=warehouse_id) - + if stock_item_id: query = query.filter_by(stock_item_id=stock_item_id) - + if movement_type: query = query.filter_by(movement_type=movement_type) - + movements = query.order_by(StockMovement.moved_at.desc()).limit(500).all() - + warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all() - - return render_template('inventory/reports/movement_history.html', - movements=movements, - warehouses=warehouses, - stock_items=stock_items, - selected_warehouse_id=warehouse_id, - selected_stock_item_id=stock_item_id, - selected_movement_type=movement_type, - date_from=date_from, - date_to=date_to) - - -@inventory_bp.route('/inventory/reports/turnover') + + return render_template( + "inventory/reports/movement_history.html", + movements=movements, + warehouses=warehouses, + stock_items=stock_items, + selected_warehouse_id=warehouse_id, + selected_stock_item_id=stock_item_id, + selected_movement_type=movement_type, + date_from=date_from, + date_to=date_to, + ) + + +@inventory_bp.route("/inventory/reports/turnover") @login_required -@admin_or_permission_required('view_inventory_reports') +@admin_or_permission_required("view_inventory_reports") def reports_turnover(): """Inventory turnover analysis""" - date_from = request.args.get('date_from') - date_to = request.args.get('date_to') - + date_from = request.args.get("date_from") + date_to = request.args.get("date_to") + if not date_from: - date_from = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d') + date_from = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") if not date_to: - date_to = datetime.now().strftime('%Y-%m-%d') - + date_to = datetime.now().strftime("%Y-%m-%d") + try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d') - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d') + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d") + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d") date_to_obj = date_to_obj.replace(hour=23, minute=59, second=59) except ValueError: date_from_obj = datetime.now() - timedelta(days=365) date_to_obj = datetime.now() - - items_with_sales = db.session.query( - StockItem, - func.sum(StockMovement.quantity).label('total_sold') - ).join(StockMovement).filter( - StockMovement.movement_type == 'sale', - StockMovement.moved_at >= date_from_obj, - StockMovement.moved_at <= date_to_obj, - StockMovement.quantity < 0 - ).group_by(StockItem.id).all() - + + items_with_sales = ( + db.session.query(StockItem, func.sum(StockMovement.quantity).label("total_sold")) + .join(StockMovement) + .filter( + StockMovement.movement_type == "sale", + StockMovement.moved_at >= date_from_obj, + StockMovement.moved_at <= date_to_obj, + StockMovement.quantity < 0, + ) + .group_by(StockItem.id) + .all() + ) + turnover_data = [] for item, total_sold in items_with_sales: - avg_stock = db.session.query( - func.avg(WarehouseStock.quantity_on_hand) - ).filter_by(stock_item_id=item.id).scalar() or 0 - + avg_stock = ( + db.session.query(func.avg(WarehouseStock.quantity_on_hand)).filter_by(stock_item_id=item.id).scalar() or 0 + ) + days_in_period = (date_to_obj - date_from_obj).days turnover_rate = 0 if avg_stock > 0: - turnover_rate = abs(float(total_sold or 0)) / float(avg_stock) * (365 / days_in_period) if days_in_period > 0 else 0 - - turnover_data.append({ - 'item': item, - 'total_sold': abs(float(total_sold or 0)), - 'avg_stock': float(avg_stock), - 'turnover_rate': turnover_rate - }) - - turnover_data.sort(key=lambda x: x['turnover_rate'], reverse=True) - - return render_template('inventory/reports/turnover.html', - turnover_data=turnover_data, - date_from=date_from, - date_to=date_to) - - -@inventory_bp.route('/inventory/reports/low-stock') + turnover_rate = ( + abs(float(total_sold or 0)) / float(avg_stock) * (365 / days_in_period) if days_in_period > 0 else 0 + ) + + turnover_data.append( + { + "item": item, + "total_sold": abs(float(total_sold or 0)), + "avg_stock": float(avg_stock), + "turnover_rate": turnover_rate, + } + ) + + turnover_data.sort(key=lambda x: x["turnover_rate"], reverse=True) + + return render_template( + "inventory/reports/turnover.html", turnover_data=turnover_data, date_from=date_from, date_to=date_to + ) + + +@inventory_bp.route("/inventory/reports/low-stock") @login_required -@admin_or_permission_required('view_inventory_reports') +@admin_or_permission_required("view_inventory_reports") def reports_low_stock(): """Low stock report""" items = StockItem.query.filter_by(is_active=True, is_trackable=True).all() - + low_stock_items = [] for item in items: if item.reorder_point: stock_levels = WarehouseStock.query.filter_by(stock_item_id=item.id).all() for stock in stock_levels: if stock.quantity_on_hand < item.reorder_point: - low_stock_items.append({ - 'item': item, - 'warehouse': stock.warehouse, - 'quantity_on_hand': stock.quantity_on_hand, - 'reorder_point': item.reorder_point, - 'reorder_quantity': item.reorder_quantity or 0, - 'shortfall': item.reorder_point - stock.quantity_on_hand - }) - - return render_template('inventory/reports/low_stock.html', - low_stock_items=low_stock_items) - + low_stock_items.append( + { + "item": item, + "warehouse": stock.warehouse, + "quantity_on_hand": stock.quantity_on_hand, + "reorder_point": item.reorder_point, + "reorder_quantity": item.reorder_quantity or 0, + "shortfall": item.reorder_point - stock.quantity_on_hand, + } + ) + + return render_template("inventory/reports/low_stock.html", low_stock_items=low_stock_items) diff --git a/app/routes/invoice_approvals.py b/app/routes/invoice_approvals.py index 96235df2..252d7484 100644 --- a/app/routes/invoice_approvals.py +++ b/app/routes/invoice_approvals.py @@ -10,120 +10,109 @@ from app.utils.permissions import admin_or_permission_required import json -invoice_approvals_bp = Blueprint('invoice_approvals', __name__) +invoice_approvals_bp = Blueprint("invoice_approvals", __name__) -@invoice_approvals_bp.route('/invoices//request-approval', methods=['GET', 'POST']) +@invoice_approvals_bp.route("/invoices//request-approval", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_invoices') +@admin_or_permission_required("create_invoices") def request_approval(invoice_id): """Request approval for an invoice""" invoice = Invoice.query.get_or_404(invoice_id) service = InvoiceApprovalService() - + # Check if approval already exists existing = service.get_invoice_approval(invoice_id) - if existing and existing.status == 'pending': - flash(_('An approval request is already pending for this invoice.'), 'error') - return redirect(url_for('invoices.view_invoice', invoice_id=invoice_id)) - - if request.method == 'POST': + if existing and existing.status == "pending": + flash(_("An approval request is already pending for this invoice."), "error") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) + + if request.method == "POST": # Get approvers from form - approvers_json = request.form.get('approvers', '[]') + approvers_json = request.form.get("approvers", "[]") try: approvers = json.loads(approvers_json) except: - approvers = [int(request.form.get('approver_id', 0))] - + approvers = [int(request.form.get("approver_id", 0))] + if not approvers or not any(approvers): - flash(_('Please select at least one approver.'), 'error') - return render_template('invoice_approvals/request.html', invoice=invoice, users=User.query.filter_by(is_active=True).all()) - - result = service.request_approval( - invoice_id=invoice_id, - requested_by=current_user.id, - approvers=approvers - ) - - if result['success']: - flash(_('Approval request created successfully.'), 'success') - return redirect(url_for('invoices.view_invoice', invoice_id=invoice_id)) + flash(_("Please select at least one approver."), "error") + return render_template( + "invoice_approvals/request.html", invoice=invoice, users=User.query.filter_by(is_active=True).all() + ) + + result = service.request_approval(invoice_id=invoice_id, requested_by=current_user.id, approvers=approvers) + + if result["success"]: + flash(_("Approval request created successfully."), "success") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) else: - flash(result['message'], 'error') - + flash(result["message"], "error") + users = User.query.filter_by(is_active=True).all() - return render_template('invoice_approvals/request.html', invoice=invoice, users=users) + return render_template("invoice_approvals/request.html", invoice=invoice, users=users) -@invoice_approvals_bp.route('/invoice-approvals') +@invoice_approvals_bp.route("/invoice-approvals") @login_required def list_approvals(): """List pending approvals""" service = InvoiceApprovalService() pending_approvals = service.list_pending_approvals(user_id=current_user.id) - - return render_template('invoice_approvals/list.html', approvals=pending_approvals) + return render_template("invoice_approvals/list.html", approvals=pending_approvals) -@invoice_approvals_bp.route('/invoice-approvals//approve', methods=['POST']) + +@invoice_approvals_bp.route("/invoice-approvals//approve", methods=["POST"]) @login_required def approve(approval_id): """Approve an invoice""" service = InvoiceApprovalService() - comments = request.form.get('comments', '').strip() or None - - result = service.approve( - approval_id=approval_id, - approver_id=current_user.id, - comments=comments - ) - - if result['success']: - flash(_('Invoice approved successfully.'), 'success') + comments = request.form.get("comments", "").strip() or None + + result = service.approve(approval_id=approval_id, approver_id=current_user.id, comments=comments) + + if result["success"]: + flash(_("Invoice approved successfully."), "success") else: - flash(result['message'], 'error') - + flash(result["message"], "error") + approval = service.get_approval(approval_id) - return redirect(url_for('invoices.view_invoice', invoice_id=approval.invoice_id)) + return redirect(url_for("invoices.view_invoice", invoice_id=approval.invoice_id)) -@invoice_approvals_bp.route('/invoice-approvals//reject', methods=['POST']) +@invoice_approvals_bp.route("/invoice-approvals//reject", methods=["POST"]) @login_required def reject(approval_id): """Reject an invoice approval""" service = InvoiceApprovalService() - reason = request.form.get('reason', '').strip() - + reason = request.form.get("reason", "").strip() + if not reason: - flash(_('Please provide a reason for rejection.'), 'error') + flash(_("Please provide a reason for rejection."), "error") approval = service.get_approval(approval_id) - return redirect(url_for('invoices.view_invoice', invoice_id=approval.invoice_id)) - - result = service.reject( - approval_id=approval_id, - rejector_id=current_user.id, - reason=reason - ) - - if result['success']: - flash(_('Invoice approval rejected.'), 'info') + return redirect(url_for("invoices.view_invoice", invoice_id=approval.invoice_id)) + + result = service.reject(approval_id=approval_id, rejector_id=current_user.id, reason=reason) + + if result["success"]: + flash(_("Invoice approval rejected."), "info") else: - flash(result['message'], 'error') - + flash(result["message"], "error") + approval = service.get_approval(approval_id) - return redirect(url_for('invoices.view_invoice', invoice_id=approval.invoice_id)) + return redirect(url_for("invoices.view_invoice", invoice_id=approval.invoice_id)) -@invoice_approvals_bp.route('/invoice-approvals/') +@invoice_approvals_bp.route("/invoice-approvals/") @login_required def view_approval(approval_id): """View approval details""" service = InvoiceApprovalService() approval = service.get_approval(approval_id) - + if not approval: - flash(_('Approval not found.'), 'error') - return redirect(url_for('invoice_approvals.list_approvals')) - - return render_template('invoice_approvals/view.html', approval=approval) + flash(_("Approval not found."), "error") + return redirect(url_for("invoice_approvals.list_approvals")) + return render_template("invoice_approvals/view.html", approval=approval) diff --git a/app/routes/invoices.py b/app/routes/invoices.py index 2f5a0f59..8ca07fc3 100644 --- a/app/routes/invoices.py +++ b/app/routes/invoices.py @@ -2,7 +2,18 @@ from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, log_event, track_event -from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings, RateOverride, ProjectCost, ExtraGood, Expense +from app.models import ( + User, + Project, + TimeEntry, + Invoice, + InvoiceItem, + Settings, + RateOverride, + ProjectCost, + ExtraGood, + Expense, +) from datetime import datetime, timedelta, date from decimal import Decimal, InvalidOperation import io @@ -16,26 +27,27 @@ track_invoice_page_viewed, track_invoice_project_selected, track_invoice_previewed, - track_invoice_generated + track_invoice_generated, ) -invoices_bp = Blueprint('invoices', __name__) +invoices_bp = Blueprint("invoices", __name__) logger = logging.getLogger(__name__) -@invoices_bp.route('/invoices') + +@invoices_bp.route("/invoices") @login_required def list_invoices(): """List all invoices - REFACTORED to use service layer with eager loading""" # Track invoice page viewed track_invoice_page_viewed(current_user.id) - + from app.services import InvoiceService - + # Get filter parameters - status = request.args.get('status', '').strip() - payment_status = request.args.get('payment_status', '').strip() - search_query = request.args.get('search', '').strip() - + status = request.args.get("status", "").strip() + payment_status = request.args.get("payment_status", "").strip() + search_query = request.args.get("search", "").strip() + # Use service layer to get invoices (prevents N+1 queries) invoice_service = InvoiceService() result = invoice_service.list_invoices( @@ -43,56 +55,58 @@ def list_invoices(): payment_status=payment_status if payment_status else None, search=search_query if search_query else None, user_id=current_user.id, - is_admin=current_user.is_admin + is_admin=current_user.is_admin, ) - - return render_template('invoices/list.html', invoices=result['invoices'], summary=result['summary']) -@invoices_bp.route('/invoices/create', methods=['GET', 'POST']) + return render_template("invoices/list.html", invoices=result["invoices"], summary=result["summary"]) + + +@invoices_bp.route("/invoices/create", methods=["GET", "POST"]) @login_required def create_invoice(): """Create a new invoice""" - if request.method == 'POST': + if request.method == "POST": # Get form data - project_id = request.form.get('project_id', type=int) - client_name = request.form.get('client_name', '').strip() - client_email = request.form.get('client_email', '').strip() - client_address = request.form.get('client_address', '').strip() - due_date_str = request.form.get('due_date', '').strip() - tax_rate = request.form.get('tax_rate', '0').strip() - notes = request.form.get('notes', '').strip() - terms = request.form.get('terms', '').strip() - + project_id = request.form.get("project_id", type=int) + client_name = request.form.get("client_name", "").strip() + client_email = request.form.get("client_email", "").strip() + client_address = request.form.get("client_address", "").strip() + due_date_str = request.form.get("due_date", "").strip() + tax_rate = request.form.get("tax_rate", "0").strip() + notes = request.form.get("notes", "").strip() + terms = request.form.get("terms", "").strip() + # Validate required fields if not project_id or not client_name or not due_date_str: - flash(_('Project, client name, and due date are required'), 'error') - return render_template('invoices/create.html') - + flash(_("Project, client name, and due date are required"), "error") + return render_template("invoices/create.html") + try: - due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date() + due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid due date format'), 'error') - return render_template('invoices/create.html') - + flash(_("Invalid due date format"), "error") + return render_template("invoices/create.html") + try: tax_rate = Decimal(tax_rate) except ValueError: - flash(_('Invalid tax rate format'), 'error') - return render_template('invoices/create.html') - + flash(_("Invalid tax rate format"), "error") + return render_template("invoices/create.html") + # Get project project = Project.query.get(project_id) if not project: - flash(_('Selected project not found'), 'error') - return render_template('invoices/create.html') - + flash(_("Selected project not found"), "error") + return render_template("invoices/create.html") + # Get quote_id from project if it exists - quote_id = project.quote_id if hasattr(project, 'quote_id') else None - + quote_id = project.quote_id if hasattr(project, "quote_id") else None + # If quote exists, try to get payment terms and calculate due_date quote = None if quote_id: from app.models import Quote + quote = Quote.query.get(quote_id) if quote and quote.payment_terms: # Calculate due_date from payment terms @@ -102,24 +116,22 @@ def create_invoice(): due_date = calculated_due_date # Override if user provided a different due_date if due_date_str: - due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date() + due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() except ValueError: pass # Use calculated date if parsing fails - + # Generate invoice number invoice_number = Invoice.generate_invoice_number() - + # Track project selected for invoice - track_invoice_project_selected(current_user.id, { - "project_id": project_id, - "has_email": bool(client_email), - "has_tax": tax_rate > 0 - }) - + track_invoice_project_selected( + current_user.id, {"project_id": project_id, "has_email": bool(client_email), "has_tax": tax_rate > 0} + ) + # Get currency from settings settings = Settings.get_settings() - currency_code = settings.currency if settings else 'USD' - + currency_code = settings.currency if settings else "USD" + # Create invoice invoice = Invoice( invoice_number=invoice_number, @@ -134,136 +146,152 @@ def create_invoice(): tax_rate=tax_rate, notes=notes, terms=terms, - currency_code=currency_code + currency_code=currency_code, ) - + db.session.add(invoice) - if not safe_commit('create_invoice', {'invoice_number': invoice_number, 'project_id': project_id}): - flash(_('Could not create invoice due to a database error. Please check server logs.'), 'error') - return render_template('invoices/create.html') - + if not safe_commit("create_invoice", {"invoice_number": invoice_number, "project_id": project_id}): + flash(_("Could not create invoice due to a database error. Please check server logs."), "error") + return render_template("invoices/create.html") + # Track invoice created - track_invoice_generated(current_user.id, { - "invoice_id": invoice.id, - "invoice_number": invoice_number, - "has_tax": float(tax_rate) > 0, - "has_notes": bool(notes) - }) - - flash(f'Invoice {invoice_number} created successfully', 'success') - return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) - + track_invoice_generated( + current_user.id, + { + "invoice_id": invoice.id, + "invoice_number": invoice_number, + "has_tax": float(tax_rate) > 0, + "has_notes": bool(notes), + }, + ) + + flash(f"Invoice {invoice_number} created successfully", "success") + return redirect(url_for("invoices.edit_invoice", invoice_id=invoice.id)) + # GET request - show form - projects = Project.query.filter_by(status='active', billable=True).order_by(Project.name).all() + projects = Project.query.filter_by(status="active", billable=True).order_by(Project.name).all() settings = Settings.get_settings() - + # Set default due date to 30 days from now - default_due_date = (datetime.utcnow() + timedelta(days=30)).strftime('%Y-%m-%d') - - return render_template('invoices/create.html', - projects=projects, - settings=settings, - default_due_date=default_due_date) - -@invoices_bp.route('/invoices/') + default_due_date = (datetime.utcnow() + timedelta(days=30)).strftime("%Y-%m-%d") + + return render_template( + "invoices/create.html", projects=projects, settings=settings, default_due_date=default_due_date + ) + + +@invoices_bp.route("/invoices/") @login_required def view_invoice(invoice_id): """View invoice details""" from app.models import InvoiceTemplate + invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - flash(_('You do not have permission to view this invoice'), 'error') - return redirect(url_for('invoices.list_invoices')) - + flash(_("You do not have permission to view this invoice"), "error") + return redirect(url_for("invoices.list_invoices")) + # Track invoice previewed - track_invoice_previewed(current_user.id, { - "invoice_id": invoice.id, - "invoice_number": invoice.invoice_number - }) - + track_invoice_previewed(current_user.id, {"invoice_id": invoice.id, "invoice_number": invoice.invoice_number}) + # Get email templates for selection email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all() - + # Get email history from app.models import InvoiceEmail - email_history = InvoiceEmail.query.filter_by(invoice_id=invoice_id)\ - .order_by(InvoiceEmail.sent_at.desc())\ - .all() - + + email_history = InvoiceEmail.query.filter_by(invoice_id=invoice_id).order_by(InvoiceEmail.sent_at.desc()).all() + # Get approval information from app.services.invoice_approval_service import InvoiceApprovalService + approval_service = InvoiceApprovalService() approval = approval_service.get_invoice_approval(invoice_id) - - return render_template('invoices/view.html', invoice=invoice, email_templates=email_templates, email_history=email_history, approval=approval) -@invoices_bp.route('/invoices//edit', methods=['GET', 'POST']) + return render_template( + "invoices/view.html", + invoice=invoice, + email_templates=email_templates, + email_history=email_history, + approval=approval, + ) + + +@invoices_bp.route("/invoices//edit", methods=["GET", "POST"]) @login_required def edit_invoice(invoice_id): """Edit invoice""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - flash(_('You do not have permission to edit this invoice'), 'error') - return redirect(url_for('invoices.list_invoices')) - - if request.method == 'POST': + flash(_("You do not have permission to edit this invoice"), "error") + return redirect(url_for("invoices.list_invoices")) + + if request.method == "POST": # Update invoice details - invoice.client_name = request.form.get('client_name', '').strip() - invoice.client_email = request.form.get('client_email', '').strip() - invoice.client_address = request.form.get('client_address', '').strip() - invoice.due_date = datetime.strptime(request.form.get('due_date'), '%Y-%m-%d').date() - invoice.tax_rate = Decimal(request.form.get('tax_rate', '0')) - invoice.notes = request.form.get('notes', '').strip() - invoice.terms = request.form.get('terms', '').strip() - + invoice.client_name = request.form.get("client_name", "").strip() + invoice.client_email = request.form.get("client_email", "").strip() + invoice.client_address = request.form.get("client_address", "").strip() + invoice.due_date = datetime.strptime(request.form.get("due_date"), "%Y-%m-%d").date() + invoice.tax_rate = Decimal(request.form.get("tax_rate", "0")) + invoice.notes = request.form.get("notes", "").strip() + invoice.terms = request.form.get("terms", "").strip() + # Update items - item_ids = request.form.getlist('item_id[]') - descriptions = request.form.getlist('description[]') - quantities = request.form.getlist('quantity[]') - unit_prices = request.form.getlist('unit_price[]') - + item_ids = request.form.getlist("item_id[]") + descriptions = request.form.getlist("description[]") + quantities = request.form.getlist("quantity[]") + unit_prices = request.form.getlist("unit_price[]") + # Remove existing items invoice.items.delete() - + # Add new items for i in range(len(descriptions)): if descriptions[i].strip() and quantities[i] and unit_prices[i]: try: quantity = Decimal(quantities[i]) unit_price = Decimal(unit_prices[i]) - + # Get stock item info if provided - stock_item_id = request.form.getlist('item_stock_item_id[]') - warehouse_id = request.form.getlist('item_warehouse_id[]') - - stock_item_id_val = int(stock_item_id[i]) if i < len(stock_item_id) and stock_item_id[i] and stock_item_id[i].strip() else None - warehouse_id_val = int(warehouse_id[i]) if i < len(warehouse_id) and warehouse_id[i] and warehouse_id[i].strip() else None - + stock_item_id = request.form.getlist("item_stock_item_id[]") + warehouse_id = request.form.getlist("item_warehouse_id[]") + + stock_item_id_val = ( + int(stock_item_id[i]) + if i < len(stock_item_id) and stock_item_id[i] and stock_item_id[i].strip() + else None + ) + warehouse_id_val = ( + int(warehouse_id[i]) + if i < len(warehouse_id) and warehouse_id[i] and warehouse_id[i].strip() + else None + ) + item = InvoiceItem( invoice_id=invoice.id, description=descriptions[i].strip(), quantity=quantity, unit_price=unit_price, stock_item_id=stock_item_id_val, - warehouse_id=warehouse_id_val + warehouse_id=warehouse_id_val, ) db.session.add(item) except ValueError: - flash(f'Invalid quantity or price for item {i+1}', 'error') + flash(f"Invalid quantity or price for item {i+1}", "error") continue - + # Update expenses - expense_ids = request.form.getlist('expense_id[]') - + expense_ids = request.form.getlist("expense_id[]") + # Unlink expenses not in the list for expense in invoice.expenses.all(): if str(expense.id) not in expense_ids: expense.unmark_as_invoiced() - + # Link expenses in the list if expense_ids: for expense_id in expense_ids: @@ -273,137 +301,164 @@ def edit_invoice(invoice_id): expense.mark_as_invoiced(invoice.id) except (ValueError, AttributeError): continue - + # Update extra goods - good_ids = request.form.getlist('good_id[]') - good_names = request.form.getlist('good_name[]') - good_descriptions = request.form.getlist('good_description[]') - good_categories = request.form.getlist('good_category[]') - good_quantities = request.form.getlist('good_quantity[]') - good_unit_prices = request.form.getlist('good_unit_price[]') - good_skus = request.form.getlist('good_sku[]') - + good_ids = request.form.getlist("good_id[]") + good_names = request.form.getlist("good_name[]") + good_descriptions = request.form.getlist("good_description[]") + good_categories = request.form.getlist("good_category[]") + good_quantities = request.form.getlist("good_quantity[]") + good_unit_prices = request.form.getlist("good_unit_price[]") + good_skus = request.form.getlist("good_sku[]") + # Remove existing extra goods invoice.extra_goods.delete() - + # Add new extra goods for i in range(len(good_names)): if good_names[i].strip() and good_quantities[i] and good_unit_prices[i]: try: quantity = Decimal(good_quantities[i]) unit_price = Decimal(good_unit_prices[i]) - + good = ExtraGood( name=good_names[i].strip(), - description=good_descriptions[i].strip() if i < len(good_descriptions) and good_descriptions[i] else None, - category=good_categories[i] if i < len(good_categories) and good_categories[i] else 'product', + description=( + good_descriptions[i].strip() + if i < len(good_descriptions) and good_descriptions[i] + else None + ), + category=good_categories[i] if i < len(good_categories) and good_categories[i] else "product", quantity=quantity, unit_price=unit_price, sku=good_skus[i].strip() if i < len(good_skus) and good_skus[i] else None, invoice_id=invoice.id, created_by=current_user.id, - currency_code=invoice.currency_code + currency_code=invoice.currency_code, ) db.session.add(good) except ValueError: - flash(f'Invalid quantity or price for extra good {i+1}', 'error') + flash(f"Invalid quantity or price for extra good {i+1}", "error") continue - + # Reserve stock for invoice items with stock items from app.models import StockReservation - + for item in invoice.items: if item.is_stock_item and item.stock_item_id and item.warehouse_id: # Check if reservation already exists existing = StockReservation.query.filter_by( stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, - reservation_type='invoice', + reservation_type="invoice", reservation_id=invoice.id, - status='reserved' + status="reserved", ).first() - + if not existing: try: StockReservation.create_reservation( stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, quantity=item.quantity, - reservation_type='invoice', + reservation_type="invoice", reservation_id=invoice.id, reserved_by=current_user.id, - expires_in_days=None # Invoice reservations don't expire + expires_in_days=None, # Invoice reservations don't expire ) except ValueError as e: - flash(_('Warning: Could not reserve stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning') - + flash( + _( + "Warning: Could not reserve stock for item %(item)s: %(error)s", + item=item.description, + error=str(e), + ), + "warning", + ) + # Calculate totals invoice.calculate_totals() - if not safe_commit('edit_invoice', {'invoice_id': invoice.id}): - flash(_('Could not update invoice due to a database error. Please check server logs.'), 'error') - return render_template('invoices/edit.html', invoice=invoice, projects=Project.query.filter_by(status='active').order_by(Project.name).all()) - - flash(_('Invoice updated successfully'), 'success') - return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id)) - + if not safe_commit("edit_invoice", {"invoice_id": invoice.id}): + flash(_("Could not update invoice due to a database error. Please check server logs."), "error") + return render_template( + "invoices/edit.html", + invoice=invoice, + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), + ) + + flash(_("Invoice updated successfully"), "success") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice.id)) + # GET request - show edit form from app.models import InvoiceTemplate, StockItem, Warehouse import json - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all() stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - + # Prepare stock items and warehouses for JavaScript - stock_items_json = json.dumps([{ - 'id': item.id, - 'sku': item.sku, - 'name': item.name, - 'default_price': float(item.default_price) if item.default_price else None, - 'default_cost': float(item.default_cost) if item.default_cost else None, - 'unit': item.unit or 'pcs', - 'description': item.name - } for item in stock_items]) - - warehouses_json = json.dumps([{ - 'id': wh.id, - 'code': wh.code, - 'name': wh.name - } for wh in warehouses]) - - return render_template('invoices/edit.html', invoice=invoice, projects=projects, email_templates=email_templates, stock_items=stock_items, warehouses=warehouses, stock_items_json=stock_items_json, warehouses_json=warehouses_json) - -@invoices_bp.route('/invoices//status', methods=['POST']) + stock_items_json = json.dumps( + [ + { + "id": item.id, + "sku": item.sku, + "name": item.name, + "default_price": float(item.default_price) if item.default_price else None, + "default_cost": float(item.default_cost) if item.default_cost else None, + "unit": item.unit or "pcs", + "description": item.name, + } + for item in stock_items + ] + ) + + warehouses_json = json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses]) + + return render_template( + "invoices/edit.html", + invoice=invoice, + projects=projects, + email_templates=email_templates, + stock_items=stock_items, + warehouses=warehouses, + stock_items_json=stock_items_json, + warehouses_json=warehouses_json, + ) + + +@invoices_bp.route("/invoices//status", methods=["POST"]) @login_required def update_invoice_status(invoice_id): """Update invoice status""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - return jsonify({'error': 'Permission denied'}), 403 - - new_status = request.form.get('new_status') - if new_status not in ['draft', 'sent', 'paid', 'cancelled']: - return jsonify({'error': 'Invalid status'}), 400 - + return jsonify({"error": "Permission denied"}), 403 + + new_status = request.form.get("new_status") + if new_status not in ["draft", "sent", "paid", "cancelled"]: + return jsonify({"error": "Invalid status"}), 400 + invoice.status = new_status - + # Auto-update payment status if marking as paid - if new_status == 'paid' and invoice.payment_status != 'fully_paid': + if new_status == "paid" and invoice.payment_status != "fully_paid": invoice.amount_paid = invoice.total_amount - invoice.payment_status = 'fully_paid' + invoice.payment_status = "fully_paid" if not invoice.payment_date: invoice.payment_date = datetime.utcnow().date() - + # Reduce stock when invoice is sent or paid (if configured) from app.models import StockMovement, StockReservation import os - - reduce_on_sent = os.getenv('INVENTORY_REDUCE_ON_INVOICE_SENT', 'true').lower() == 'true' - reduce_on_paid = os.getenv('INVENTORY_REDUCE_ON_INVOICE_PAID', 'false').lower() == 'true' - - if (new_status == 'sent' and reduce_on_sent) or (new_status == 'paid' and reduce_on_paid): + + reduce_on_sent = os.getenv("INVENTORY_REDUCE_ON_INVOICE_SENT", "true").lower() == "true" + reduce_on_paid = os.getenv("INVENTORY_REDUCE_ON_INVOICE_PAID", "false").lower() == "true" + + if (new_status == "sent" and reduce_on_sent) or (new_status == "paid" and reduce_on_paid): for item in invoice.items: if item.is_stock_item and item.stock_item_id and item.warehouse_id: try: @@ -411,192 +466,205 @@ def update_invoice_status(invoice_id): reservation = StockReservation.query.filter_by( stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, - reservation_type='invoice', + reservation_type="invoice", reservation_id=invoice.id, - status='reserved' + status="reserved", ).first() - + if reservation: reservation.fulfill() - + # Create stock movement (sale) StockMovement.record_movement( - movement_type='sale', + movement_type="sale", stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, quantity=-item.quantity, # Negative for removal moved_by=current_user.id, - reference_type='invoice', + reference_type="invoice", reference_id=invoice.id, unit_cost=item.stock_item.default_cost if item.stock_item else None, - reason=f'Invoice {invoice.invoice_number}', - update_stock=True + reason=f"Invoice {invoice.invoice_number}", + update_stock=True, ) except Exception as e: - flash(_('Warning: Could not reduce stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning') - - if not safe_commit('update_invoice_status', {'invoice_id': invoice.id, 'status': new_status}): - return jsonify({'error': 'Database error while updating status'}), 500 - - return jsonify({'success': True, 'status': new_status}) + flash( + _( + "Warning: Could not reduce stock for item %(item)s: %(error)s", + item=item.description, + error=str(e), + ), + "warning", + ) + + if not safe_commit("update_invoice_status", {"invoice_id": invoice.id, "status": new_status}): + return jsonify({"error": "Database error while updating status"}), 500 + return jsonify({"success": True, "status": new_status}) -@invoices_bp.route('/invoices//delete', methods=['POST']) + +@invoices_bp.route("/invoices//delete", methods=["POST"]) @login_required def delete_invoice(invoice_id): """Delete invoice""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - flash(_('You do not have permission to delete this invoice'), 'error') - return redirect(url_for('invoices.list_invoices')) - + flash(_("You do not have permission to delete this invoice"), "error") + return redirect(url_for("invoices.list_invoices")) + invoice_number = invoice.invoice_number db.session.delete(invoice) - if not safe_commit('delete_invoice', {'invoice_id': invoice.id}): - flash(_('Could not delete invoice due to a database error. Please check server logs.'), 'error') - return redirect(url_for('invoices.list_invoices')) - - flash(f'Invoice {invoice_number} deleted successfully', 'success') - return redirect(url_for('invoices.list_invoices')) - -@invoices_bp.route('/invoices/bulk-delete', methods=['POST']) + if not safe_commit("delete_invoice", {"invoice_id": invoice.id}): + flash(_("Could not delete invoice due to a database error. Please check server logs."), "error") + return redirect(url_for("invoices.list_invoices")) + + flash(f"Invoice {invoice_number} deleted successfully", "success") + return redirect(url_for("invoices.list_invoices")) + + +@invoices_bp.route("/invoices/bulk-delete", methods=["POST"]) @login_required def bulk_delete_invoices(): """Delete multiple invoices at once""" - invoice_ids = request.form.getlist('invoice_ids[]') - + invoice_ids = request.form.getlist("invoice_ids[]") + if not invoice_ids: - flash(_('No invoices selected for deletion'), 'warning') - return redirect(url_for('invoices.list_invoices')) - + flash(_("No invoices selected for deletion"), "warning") + return redirect(url_for("invoices.list_invoices")) + deleted_count = 0 skipped_count = 0 errors = [] - + for invoice_id_str in invoice_ids: try: invoice_id = int(invoice_id_str) invoice = Invoice.query.get(invoice_id) - + if not invoice: continue - + # Check permissions if not current_user.is_admin and invoice.created_by != current_user.id: skipped_count += 1 errors.append(f"'{invoice.invoice_number}': No permission") continue - + invoice_number = invoice.invoice_number db.session.delete(invoice) deleted_count += 1 - + except Exception as e: skipped_count += 1 errors.append(f"ID {invoice_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: - if not safe_commit('bulk_delete_invoices', {'count': deleted_count}): - flash(_('Could not delete invoices due to a database error. Please check server logs.'), 'error') - return redirect(url_for('invoices.list_invoices')) - + if not safe_commit("bulk_delete_invoices", {"count": deleted_count}): + flash(_("Could not delete invoices due to a database error. Please check server logs."), "error") + return redirect(url_for("invoices.list_invoices")) + # Show appropriate messages if deleted_count > 0: - flash(f'Successfully deleted {deleted_count} invoice{"s" if deleted_count != 1 else ""}', 'success') - + flash(f'Successfully deleted {deleted_count} invoice{"s" if deleted_count != 1 else ""}', "success") + if skipped_count > 0: - flash(f'Skipped {skipped_count} invoice{"s" if skipped_count != 1 else ""}: {"; ".join(errors[:3])}', 'warning') - - return redirect(url_for('invoices.list_invoices')) + flash(f'Skipped {skipped_count} invoice{"s" if skipped_count != 1 else ""}: {"; ".join(errors[:3])}', "warning") + + return redirect(url_for("invoices.list_invoices")) + -@invoices_bp.route('/invoices/bulk-status', methods=['POST']) +@invoices_bp.route("/invoices/bulk-status", methods=["POST"]) @login_required def bulk_update_status(): """Update status for multiple invoices at once""" - invoice_ids = request.form.getlist('invoice_ids[]') - new_status = request.form.get('status', '').strip() - + invoice_ids = request.form.getlist("invoice_ids[]") + new_status = request.form.get("status", "").strip() + if not invoice_ids: - flash(_('No invoices selected'), 'warning') - return redirect(url_for('invoices.list_invoices')) - + flash(_("No invoices selected"), "warning") + return redirect(url_for("invoices.list_invoices")) + # Validate status - valid_statuses = ['draft', 'sent', 'paid', 'overdue', 'cancelled'] + valid_statuses = ["draft", "sent", "paid", "overdue", "cancelled"] if not new_status or new_status not in valid_statuses: - flash(_('Invalid status value'), 'error') - return redirect(url_for('invoices.list_invoices')) - + flash(_("Invalid status value"), "error") + return redirect(url_for("invoices.list_invoices")) + updated_count = 0 skipped_count = 0 - + for invoice_id_str in invoice_ids: try: invoice_id = int(invoice_id_str) invoice = Invoice.query.get(invoice_id) - + if not invoice: continue - + # Check permissions if not current_user.is_admin and invoice.created_by != current_user.id: skipped_count += 1 continue - + invoice.status = new_status - + # Auto-update payment status if marking as paid - if new_status == 'paid' and invoice.payment_status != 'fully_paid': + if new_status == "paid" and invoice.payment_status != "fully_paid": invoice.amount_paid = invoice.total_amount - invoice.payment_status = 'fully_paid' + invoice.payment_status = "fully_paid" if not invoice.payment_date: invoice.payment_date = datetime.utcnow().date() - + updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: - if not safe_commit('bulk_update_invoice_status', {'count': updated_count, 'status': new_status}): - flash(_('Could not update invoices due to a database error'), 'error') - return redirect(url_for('invoices.list_invoices')) - - flash(f'Successfully updated {updated_count} invoice{"s" if updated_count != 1 else ""} to {new_status}', 'success') - + if not safe_commit("bulk_update_invoice_status", {"count": updated_count, "status": new_status}): + flash(_("Could not update invoices due to a database error"), "error") + return redirect(url_for("invoices.list_invoices")) + + flash( + f'Successfully updated {updated_count} invoice{"s" if updated_count != 1 else ""} to {new_status}', + "success", + ) + if skipped_count > 0: - flash(f'Skipped {skipped_count} invoice{"s" if skipped_count != 1 else ""} (no permission)', 'warning') - - return redirect(url_for('invoices.list_invoices')) + flash(f'Skipped {skipped_count} invoice{"s" if skipped_count != 1 else ""} (no permission)', "warning") -@invoices_bp.route('/invoices//generate-from-time', methods=['GET', 'POST']) + return redirect(url_for("invoices.list_invoices")) + + +@invoices_bp.route("/invoices//generate-from-time", methods=["GET", "POST"]) @login_required def generate_from_time(invoice_id): """Generate invoice items from time entries""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - flash(_('You do not have permission to edit this invoice'), 'error') - return redirect(url_for('invoices.list_invoices')) - - if request.method == 'POST': + flash(_("You do not have permission to edit this invoice"), "error") + return redirect(url_for("invoices.list_invoices")) + + if request.method == "POST": # Get selected time entries, costs, expenses, and extra goods - selected_entries = request.form.getlist('time_entries[]') - selected_costs = request.form.getlist('project_costs[]') - selected_expenses = request.form.getlist('expenses[]') - selected_goods = request.form.getlist('extra_goods[]') - + selected_entries = request.form.getlist("time_entries[]") + selected_costs = request.form.getlist("project_costs[]") + selected_expenses = request.form.getlist("expenses[]") + selected_goods = request.form.getlist("extra_goods[]") + if not selected_entries and not selected_costs and not selected_expenses and not selected_goods: - flash(_('No time entries, costs, expenses, or extra goods selected'), 'error') - return redirect(url_for('invoices.generate_from_time', invoice_id=invoice.id)) - + flash(_("No time entries, costs, expenses, or extra goods selected"), "error") + return redirect(url_for("invoices.generate_from_time", invoice_id=invoice.id)) + # Clear existing items invoice.items.delete() - - total_prepaid_allocated = Decimal('0') + + total_prepaid_allocated = Decimal("0") prepaid_allocator = None # Process time entries @@ -623,59 +691,59 @@ def generate_from_time(invoice_id): if key not in grouped_entries: grouped_entries[key] = { - 'description': description, - 'entries': [], - 'total_hours': Decimal('0'), + "description": description, + "entries": [], + "total_hours": Decimal("0"), } - grouped_entries[key]['entries'].append(processed) - grouped_entries[key]['total_hours'] += processed.billable_hours + grouped_entries[key]["entries"].append(processed) + grouped_entries[key]["total_hours"] += processed.billable_hours # Create invoice items from time entries for group in grouped_entries.values(): - if group['total_hours'] <= 0: + if group["total_hours"] <= 0: continue hourly_rate = RateOverride.resolve_rate(invoice.project) item = InvoiceItem( invoice_id=invoice.id, - description=group['description'], - quantity=group['total_hours'], + description=group["description"], + quantity=group["total_hours"], unit_price=hourly_rate, - time_entry_ids=','.join(str(processed.entry.id) for processed in group['entries']) + time_entry_ids=",".join(str(processed.entry.id) for processed in group["entries"]), ) db.session.add(item) - + # Process project costs if selected_costs: costs = ProjectCost.query.filter(ProjectCost.id.in_(selected_costs)).all() - + for cost in costs: # Create invoice item for each cost item = InvoiceItem( invoice_id=invoice.id, description=f"{cost.description} ({cost.category.title()})", quantity=1, # Costs are typically a single unit - unit_price=cost.amount + unit_price=cost.amount, ) db.session.add(item) - + # Mark cost as invoiced cost.mark_as_invoiced(invoice.id) - + # Process expenses if selected_expenses: expenses = Expense.query.filter(Expense.id.in_(selected_expenses)).all() - + for expense in expenses: # Mark expense as invoiced (this links it to the invoice) expense.mark_as_invoiced(invoice.id) - + # Process extra goods from project if selected_goods: goods = ExtraGood.query.filter(ExtraGood.id.in_(selected_goods)).all() - + for good in goods: # Create a copy of the good for the invoice invoice_good = ExtraGood( @@ -687,34 +755,38 @@ def generate_from_time(invoice_id): sku=good.sku, invoice_id=invoice.id, created_by=current_user.id, - currency_code=good.currency_code + currency_code=good.currency_code, ) db.session.add(invoice_good) - + # Calculate totals invoice.calculate_totals() - if not safe_commit('generate_from_time', {'invoice_id': invoice.id}): - flash(_('Could not generate items due to a database error. Please check server logs.'), 'error') - return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) - - flash(_('Invoice items generated successfully from time entries and costs'), 'success') + if not safe_commit("generate_from_time", {"invoice_id": invoice.id}): + flash(_("Could not generate items due to a database error. Please check server logs."), "error") + return redirect(url_for("invoices.edit_invoice", invoice_id=invoice.id)) + + flash(_("Invoice items generated successfully from time entries and costs"), "success") if total_prepaid_allocated and total_prepaid_allocated > 0: flash( - _('Applied %(hours)s prepaid hours for %(client)s before billing overages.', - hours=f"{total_prepaid_allocated:.2f}", - client=invoice.client_name), - 'info' + _( + "Applied %(hours)s prepaid hours for %(client)s before billing overages.", + hours=f"{total_prepaid_allocated:.2f}", + client=invoice.client_name, + ), + "info", ) - return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) - + return redirect(url_for("invoices.edit_invoice", invoice_id=invoice.id)) + # GET request - show time entry and cost selection # Get unbilled time entries for this project - time_entries = TimeEntry.query.filter( - TimeEntry.project_id == invoice.project_id, - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True - ).order_by(TimeEntry.start_time.desc()).all() - + time_entries = ( + TimeEntry.query.filter( + TimeEntry.project_id == invoice.project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True + ) + .order_by(TimeEntry.start_time.desc()) + .all() + ) + # Filter out entries already billed in other invoices unbilled_entries = [] for entry in time_entries: @@ -723,28 +795,30 @@ def generate_from_time(invoice_id): for other_invoice in invoice.project.invoices: if other_invoice.id != invoice.id: for item in other_invoice.items: - if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(','): + if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(","): already_billed = True break if already_billed: break - + if not already_billed: unbilled_entries.append(entry) - + # Get uninvoiced billable costs for this project unbilled_costs = ProjectCost.get_uninvoiced_costs(invoice.project_id) - + # Get uninvoiced billable expenses for this project unbilled_expenses = Expense.get_uninvoiced_expenses(project_id=invoice.project_id) - + # Get billable extra goods for this project (not yet on an invoice) - project_goods = ExtraGood.query.filter( - ExtraGood.project_id == invoice.project_id, - ExtraGood.invoice_id.is_(None), - ExtraGood.billable == True - ).order_by(ExtraGood.created_at.desc()).all() - + project_goods = ( + ExtraGood.query.filter( + ExtraGood.project_id == invoice.project_id, ExtraGood.invoice_id.is_(None), ExtraGood.billable == True + ) + .order_by(ExtraGood.created_at.desc()) + .all() + ) + # Calculate totals total_available_hours = sum(entry.duration_hours for entry in unbilled_entries) total_available_costs = sum(float(cost.amount) for cost in unbilled_costs) @@ -759,179 +833,168 @@ def generate_from_time(invoice_id): prepaid_summary = [] for summary in summaries: allocation_month = summary.allocation_month - prepaid_summary.append({ - 'allocation_month': allocation_month, - 'allocation_month_label': allocation_month.strftime('%Y-%m-%d') if allocation_month else '', - 'plan_hours': float(summary.plan_hours), - 'consumed_hours': float(summary.consumed_hours), - 'remaining_hours': float(summary.remaining_hours) - }) + prepaid_summary.append( + { + "allocation_month": allocation_month, + "allocation_month_label": allocation_month.strftime("%Y-%m-%d") if allocation_month else "", + "plan_hours": float(summary.plan_hours), + "consumed_hours": float(summary.consumed_hours), + "remaining_hours": float(summary.remaining_hours), + } + ) prepaid_plan_hours = float(invoice.client.prepaid_hours_decimal) - + # Get currency from settings settings = Settings.get_settings() - currency = settings.currency if settings else 'USD' - - return render_template('invoices/generate_from_time.html', - invoice=invoice, - time_entries=unbilled_entries, - project_costs=unbilled_costs, - expenses=unbilled_expenses, - extra_goods=project_goods, - total_available_hours=total_available_hours, - total_available_costs=total_available_costs, - total_available_expenses=total_available_expenses, - total_available_goods=total_available_goods, - currency=currency, - prepaid_summary=prepaid_summary, - prepaid_plan_hours=prepaid_plan_hours, - prepaid_reset_day=invoice.client.prepaid_reset_day if invoice.client else None) - -@invoices_bp.route('/invoices//export/csv') + currency = settings.currency if settings else "USD" + + return render_template( + "invoices/generate_from_time.html", + invoice=invoice, + time_entries=unbilled_entries, + project_costs=unbilled_costs, + expenses=unbilled_expenses, + extra_goods=project_goods, + total_available_hours=total_available_hours, + total_available_costs=total_available_costs, + total_available_expenses=total_available_expenses, + total_available_goods=total_available_goods, + currency=currency, + prepaid_summary=prepaid_summary, + prepaid_plan_hours=prepaid_plan_hours, + prepaid_reset_day=invoice.client.prepaid_reset_day if invoice.client else None, + ) + + +@invoices_bp.route("/invoices//export/csv") @login_required def export_invoice_csv(invoice_id): """Export invoice as CSV""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - flash(_('You do not have permission to export this invoice'), 'error') - return redirect(url_for('invoices.list_invoices')) - + flash(_("You do not have permission to export this invoice"), "error") + return redirect(url_for("invoices.list_invoices")) + # Create CSV output output = io.StringIO() writer = csv.writer(output) - + # Write header - writer.writerow(['Invoice Number', invoice.invoice_number]) - writer.writerow(['Client', invoice.client_name]) - writer.writerow(['Issue Date', invoice.issue_date.strftime('%Y-%m-%d')]) - writer.writerow(['Due Date', invoice.due_date.strftime('%Y-%m-%d')]) - writer.writerow(['Status', invoice.status]) + writer.writerow(["Invoice Number", invoice.invoice_number]) + writer.writerow(["Client", invoice.client_name]) + writer.writerow(["Issue Date", invoice.issue_date.strftime("%Y-%m-%d")]) + writer.writerow(["Due Date", invoice.due_date.strftime("%Y-%m-%d")]) + writer.writerow(["Status", invoice.status]) writer.writerow([]) - + # Write items - writer.writerow(['Description', 'Quantity (Hours)', 'Unit Price', 'Total Amount']) + writer.writerow(["Description", "Quantity (Hours)", "Unit Price", "Total Amount"]) for item in invoice.items: - writer.writerow([ - item.description, - float(item.quantity), - float(item.unit_price), - float(item.total_amount) - ]) - + writer.writerow([item.description, float(item.quantity), float(item.unit_price), float(item.total_amount)]) + # Write expenses for expense in invoice.expenses: - writer.writerow([ - f"{expense.title} ({expense.category})", - 1, - float(expense.total_amount), - float(expense.total_amount) - ]) - + writer.writerow( + [f"{expense.title} ({expense.category})", 1, float(expense.total_amount), float(expense.total_amount)] + ) + # Write goods for good in invoice.extra_goods: - writer.writerow([ - good.name, - float(good.quantity), - float(good.unit_price), - float(good.total_amount) - ]) - + writer.writerow([good.name, float(good.quantity), float(good.unit_price), float(good.total_amount)]) + writer.writerow([]) - writer.writerow(['Subtotal', '', '', float(invoice.subtotal)]) - writer.writerow(['Tax Rate', '', '', f'{float(invoice.tax_rate)}%']) - writer.writerow(['Tax Amount', '', '', float(invoice.tax_amount)]) - writer.writerow(['Total Amount', '', '', float(invoice.total_amount)]) - + writer.writerow(["Subtotal", "", "", float(invoice.subtotal)]) + writer.writerow(["Tax Rate", "", "", f"{float(invoice.tax_rate)}%"]) + writer.writerow(["Tax Amount", "", "", float(invoice.tax_amount)]) + writer.writerow(["Total Amount", "", "", float(invoice.total_amount)]) + output.seek(0) - - filename = f'invoice_{invoice.invoice_number}.csv' - + + filename = f"invoice_{invoice.invoice_number}.csv" + return send_file( - io.BytesIO(output.getvalue().encode('utf-8')), - mimetype='text/csv', - as_attachment=True, - download_name=filename + io.BytesIO(output.getvalue().encode("utf-8")), mimetype="text/csv", as_attachment=True, download_name=filename ) -@invoices_bp.route('/invoices//export/pdf') + +@invoices_bp.route("/invoices//export/pdf") @login_required def export_invoice_pdf(invoice_id): """Export invoice as PDF with optional page size selection""" logger.info(f"Invoice PDF export requested - Invoice ID: {invoice_id}, User: {current_user.username}") - + invoice = Invoice.query.get_or_404(invoice_id) logger.debug(f"Invoice found: {invoice.invoice_number}") - + if not current_user.is_admin and invoice.created_by != current_user.id: 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')) - + 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') + page_size = request.args.get("size", "A4") logger.debug(f"Page size from request: '{page_size}'") - + # Validate page size - valid_sizes = ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid'] + valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: logger.warning(f"Invalid page size '{page_size}', defaulting to A4") - page_size = 'A4' - + page_size = "A4" + logger.debug(f"Final page size: '{page_size}'") - + try: from app.utils.pdf_generator import InvoicePDFGenerator + settings = Settings.get_settings() logger.debug(f"Creating InvoicePDFGenerator with page_size='{page_size}'") pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size=page_size) logger.debug("Calling pdf_generator.generate_pdf()") pdf_bytes = pdf_generator.generate_pdf() 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), - mimetype='application/pdf', - as_attachment=True, - download_name=filename - ) + filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf" + return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename) except Exception as e: import traceback + logger.error(f"Exception in PDF generation: {e}", exc_info=True) try: 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() logger.info("Fallback PDF generated successfully") - filename = f'invoice_{invoice.invoice_number}_{page_size}.pdf' + filename = f"invoice_{invoice.invoice_number}_{page_size}.pdf" return send_file( - io.BytesIO(pdf_bytes), - mimetype='application/pdf', - as_attachment=True, - download_name=filename + io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, 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)) + 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)) + -@invoices_bp.route('/invoices//duplicate') +@invoices_bp.route("/invoices//duplicate") @login_required def duplicate_invoice(invoice_id): """Duplicate an existing invoice""" original_invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and original_invoice.created_by != current_user.id: - flash(_('You do not have permission to duplicate this invoice'), 'error') - return redirect(url_for('invoices.list_invoices')) - + flash(_("You do not have permission to duplicate this invoice"), "error") + return redirect(url_for("invoices.list_invoices")) + # Generate new invoice number new_invoice_number = Invoice.generate_invoice_number() - + # Create new invoice new_invoice = Invoice( invoice_number=new_invoice_number, @@ -945,24 +1008,26 @@ def duplicate_invoice(invoice_id): tax_rate=original_invoice.tax_rate, notes=original_invoice.notes, terms=original_invoice.terms, - currency_code=original_invoice.currency_code + currency_code=original_invoice.currency_code, ) - + db.session.add(new_invoice) - if not safe_commit('duplicate_invoice_create', {'source_invoice_id': original_invoice.id, 'new_invoice_number': new_invoice_number}): - flash(_('Could not duplicate invoice due to a database error. Please check server logs.'), 'error') - return redirect(url_for('invoices.list_invoices')) - + if not safe_commit( + "duplicate_invoice_create", {"source_invoice_id": original_invoice.id, "new_invoice_number": new_invoice_number} + ): + flash(_("Could not duplicate invoice due to a database error. Please check server logs."), "error") + return redirect(url_for("invoices.list_invoices")) + # Duplicate items for original_item in original_invoice.items: new_item = InvoiceItem( invoice_id=new_invoice.id, description=original_item.description, quantity=original_item.quantity, - unit_price=original_item.unit_price + unit_price=original_item.unit_price, ) db.session.add(new_item) - + # Duplicate extra goods for original_good in original_invoice.extra_goods: new_good = ExtraGood( @@ -974,21 +1039,21 @@ def duplicate_invoice(invoice_id): sku=original_good.sku, invoice_id=new_invoice.id, created_by=current_user.id, - currency_code=original_good.currency_code + currency_code=original_good.currency_code, ) db.session.add(new_good) - + # Calculate totals new_invoice.calculate_totals() - if not safe_commit('duplicate_invoice_finalize', {'invoice_id': new_invoice.id}): - flash(_('Could not finalize duplicated invoice due to a database error. Please check server logs.'), 'error') - return redirect(url_for('invoices.list_invoices')) - - flash(f'Invoice {new_invoice_number} created as duplicate', 'success') - return redirect(url_for('invoices.edit_invoice', invoice_id=new_invoice.id)) + if not safe_commit("duplicate_invoice_finalize", {"invoice_id": new_invoice.id}): + flash(_("Could not finalize duplicated invoice due to a database error. Please check server logs."), "error") + return redirect(url_for("invoices.list_invoices")) + flash(f"Invoice {new_invoice_number} created as duplicate", "success") + return redirect(url_for("invoices.edit_invoice", invoice_id=new_invoice.id)) -@invoices_bp.route('/invoices/export/excel') + +@invoices_bp.route("/invoices/export/excel") @login_required def export_invoices_excel(): """Export invoice list as Excel file""" @@ -997,157 +1062,159 @@ def export_invoices_excel(): invoices = Invoice.query.order_by(Invoice.created_at.desc()).all() else: invoices = Invoice.query.filter_by(created_by=current_user.id).order_by(Invoice.created_at.desc()).all() - + # Create Excel file output, filename = create_invoices_list_excel(invoices) - + # Track Excel export event - log_event("export.excel", - user_id=current_user.id, - export_type="invoices_list", - num_rows=len(invoices)) - track_event(current_user.id, "export.excel", { - "export_type": "invoices_list", - "num_rows": len(invoices) - }) - + log_event("export.excel", user_id=current_user.id, export_type="invoices_list", num_rows=len(invoices)) + track_event(current_user.id, "export.excel", {"export_type": "invoices_list", "num_rows": len(invoices)}) + return send_file( output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", as_attachment=True, - download_name=filename + download_name=filename, ) -@invoices_bp.route('/invoices//send-email', methods=['POST']) + +@invoices_bp.route("/invoices//send-email", methods=["POST"]) @login_required def send_invoice_email_route(invoice_id): """Send invoice via email""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + # Get recipient email from request - recipient_email = request.form.get('recipient_email', '').strip() or request.json.get('recipient_email', '').strip() if request.is_json else '' - + recipient_email = ( + request.form.get("recipient_email", "").strip() or request.json.get("recipient_email", "").strip() + if request.is_json + else "" + ) + if not recipient_email: # Try to use invoice client email recipient_email = invoice.client_email - + if not recipient_email: - return jsonify({'error': 'Recipient email address is required'}), 400 - + return jsonify({"error": "Recipient email address is required"}), 400 + # Get custom message if provided - custom_message = request.form.get('custom_message', '').strip() or (request.json.get('custom_message', '').strip() if request.is_json else '') - + custom_message = request.form.get("custom_message", "").strip() or ( + request.json.get("custom_message", "").strip() if request.is_json else "" + ) + # Get email template ID if provided - email_template_id = request.form.get('email_template_id', type=int) or (request.json.get('email_template_id') if request.is_json else None) - + email_template_id = request.form.get("email_template_id", type=int) or ( + request.json.get("email_template_id") if request.is_json else None + ) + try: from app.utils.email import send_invoice_email - + success, invoice_email, message = send_invoice_email( invoice=invoice, recipient_email=recipient_email, sender_user=current_user, custom_message=custom_message if custom_message else None, - email_template_id=email_template_id + email_template_id=email_template_id, ) - + if success: - flash(f'Invoice email sent successfully to {recipient_email}', 'success') - return jsonify({ - 'success': True, - 'message': message, - 'invoice_email_id': invoice_email.id if invoice_email else None - }) + flash(f"Invoice email sent successfully to {recipient_email}", "success") + return jsonify( + {"success": True, "message": message, "invoice_email_id": invoice_email.id if invoice_email else None} + ) else: - return jsonify({'error': message}), 500 - + return jsonify({"error": message}), 500 + except Exception as e: logger.error(f"Error sending invoice email: {type(e).__name__}: {str(e)}") logger.exception("Full error traceback:") - return jsonify({'error': f'Failed to send email: {str(e)}'}), 500 + return jsonify({"error": f"Failed to send email: {str(e)}"}), 500 -@invoices_bp.route('/invoices//email-history', methods=['GET']) +@invoices_bp.route("/invoices//email-history", methods=["GET"]) @login_required def get_invoice_email_history(invoice_id): """Get email history for an invoice""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + from app.models import InvoiceEmail - + # Get all email records for this invoice, ordered by most recent first - email_records = InvoiceEmail.query.filter_by(invoice_id=invoice_id)\ - .order_by(InvoiceEmail.sent_at.desc())\ - .all() - + email_records = InvoiceEmail.query.filter_by(invoice_id=invoice_id).order_by(InvoiceEmail.sent_at.desc()).all() + # Convert to list of dictionaries email_history = [email.to_dict() for email in email_records] - - return jsonify({ - 'success': True, - 'email_history': email_history, - 'count': len(email_history) - }) + + return jsonify({"success": True, "email_history": email_history, "count": len(email_history)}) -@invoices_bp.route('/invoices//resend-email/', methods=['POST']) +@invoices_bp.route("/invoices//resend-email/", methods=["POST"]) @login_required def resend_invoice_email(invoice_id, email_id): """Resend an invoice email""" invoice = Invoice.query.get_or_404(invoice_id) - + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + from app.models import InvoiceEmail + original_email = InvoiceEmail.query.get_or_404(email_id) - + # Verify the email belongs to this invoice if original_email.invoice_id != invoice_id: - return jsonify({'error': 'Email record does not belong to this invoice'}), 400 - + return jsonify({"error": "Email record does not belong to this invoice"}), 400 + # Get recipient email from request or use original - recipient_email = request.form.get('recipient_email', '').strip() or request.json.get('recipient_email', '').strip() if request.is_json else '' + recipient_email = ( + request.form.get("recipient_email", "").strip() or request.json.get("recipient_email", "").strip() + if request.is_json + else "" + ) if not recipient_email: recipient_email = original_email.recipient_email - + # Get custom message if provided - custom_message = request.form.get('custom_message', '').strip() or (request.json.get('custom_message', '').strip() if request.is_json else '') - + custom_message = request.form.get("custom_message", "").strip() or ( + request.json.get("custom_message", "").strip() if request.is_json else "" + ) + # Get email template ID if provided - email_template_id = request.form.get('email_template_id', type=int) or (request.json.get('email_template_id') if request.is_json else None) - + email_template_id = request.form.get("email_template_id", type=int) or ( + request.json.get("email_template_id") if request.is_json else None + ) + try: from app.utils.email import send_invoice_email - + success, invoice_email, message = send_invoice_email( invoice=invoice, recipient_email=recipient_email, sender_user=current_user, custom_message=custom_message if custom_message else None, - email_template_id=email_template_id + email_template_id=email_template_id, ) - + if success: - flash(f'Invoice email resent successfully to {recipient_email}', 'success') - return jsonify({ - 'success': True, - 'message': message, - 'invoice_email_id': invoice_email.id if invoice_email else None - }) + flash(f"Invoice email resent successfully to {recipient_email}", "success") + return jsonify( + {"success": True, "message": message, "invoice_email_id": invoice_email.id if invoice_email else None} + ) else: - return jsonify({'error': message}), 500 - + return jsonify({"error": message}), 500 + except Exception as e: logger.error(f"Error resending invoice email: {type(e).__name__}: {str(e)}") logger.exception("Full error traceback:") - return jsonify({'error': f'Failed to resend email: {str(e)}'}), 500 + return jsonify({"error": f"Failed to resend email: {str(e)}"}), 500 diff --git a/app/routes/invoices_refactored.py b/app/routes/invoices_refactored.py index e8bc5730..f863d39a 100644 --- a/app/routes/invoices_refactored.py +++ b/app/routes/invoices_refactored.py @@ -17,154 +17,136 @@ from app.utils.api_responses import success_response, error_response, paginated_response from app.utils.event_bus import emit_event from app.constants import WebhookEvent, InvoiceStatus -from app.utils.posthog_funnels import ( - track_invoice_page_viewed, - track_invoice_project_selected, - track_invoice_generated -) +from app.utils.posthog_funnels import track_invoice_page_viewed, track_invoice_project_selected, track_invoice_generated -invoices_bp = Blueprint('invoices', __name__) +invoices_bp = Blueprint("invoices", __name__) -@invoices_bp.route('/invoices') +@invoices_bp.route("/invoices") @login_required def list_invoices(): """List all invoices - REFACTORED VERSION""" track_invoice_page_viewed(current_user.id) - + # Get filter parameters - status = request.args.get('status', '').strip() - payment_status = request.args.get('payment_status', '').strip() - search_query = request.args.get('search', '').strip() - page = request.args.get('page', 1, type=int) - + status = request.args.get("status", "").strip() + payment_status = request.args.get("payment_status", "").strip() + search_query = request.args.get("search", "").strip() + page = request.args.get("page", 1, type=int) + # Use repository invoice_repo = InvoiceRepository() - + # Build query if current_user.is_admin: query = invoice_repo.query() else: query = invoice_repo.query().filter_by(created_by=current_user.id) - + # Apply filters if status: query = query.filter(Invoice.status == status) - + if payment_status: query = query.filter(Invoice.payment_status == payment_status) - + if search_query: like = f"%{search_query}%" - query = query.filter( - db.or_( - Invoice.invoice_number.ilike(like), - Invoice.client_name.ilike(like) - ) - ) - + query = query.filter(db.or_(Invoice.invoice_number.ilike(like), Invoice.client_name.ilike(like))) + # Paginate - invoices_pagination = query.order_by(Invoice.created_at.desc()).paginate( - page=page, - per_page=50, - error_out=False - ) - + invoices_pagination = query.order_by(Invoice.created_at.desc()).paginate(page=page, per_page=50, error_out=False) + # Calculate overdue status today = date.today() for invoice in invoices_pagination.items: invoice._is_overdue = ( - invoice.due_date and - invoice.due_date < today and - invoice.payment_status != 'fully_paid' and - invoice.status != 'paid' + invoice.due_date + and invoice.due_date < today + and invoice.payment_status != "fully_paid" + and invoice.status != "paid" ) - + # Get summary statistics if current_user.is_admin: all_invoices = invoice_repo.get_all() else: all_invoices = invoice_repo.find_by(created_by=current_user.id) - + total_invoices = len(all_invoices) total_amount = sum(inv.total_amount for inv in all_invoices) actual_paid_amount = sum(inv.amount_paid or 0 for inv in all_invoices) - fully_paid_amount = sum(inv.total_amount for inv in all_invoices if inv.payment_status == 'fully_paid') - partially_paid_amount = sum(inv.amount_paid or 0 for inv in all_invoices if inv.payment_status == 'partially_paid') - overdue_amount = sum(inv.outstanding_amount for inv in all_invoices if inv.status == 'overdue') - + fully_paid_amount = sum(inv.total_amount for inv in all_invoices if inv.payment_status == "fully_paid") + partially_paid_amount = sum(inv.amount_paid or 0 for inv in all_invoices if inv.payment_status == "partially_paid") + overdue_amount = sum(inv.outstanding_amount for inv in all_invoices if inv.status == "overdue") + summary = { - 'total_invoices': total_invoices, - 'total_amount': float(total_amount), - 'paid_amount': float(actual_paid_amount), - 'fully_paid_amount': float(fully_paid_amount), - 'partially_paid_amount': float(partially_paid_amount), - 'overdue_amount': float(overdue_amount), - 'outstanding_amount': float(total_amount - actual_paid_amount) + "total_invoices": total_invoices, + "total_amount": float(total_amount), + "paid_amount": float(actual_paid_amount), + "fully_paid_amount": float(fully_paid_amount), + "partially_paid_amount": float(partially_paid_amount), + "overdue_amount": float(overdue_amount), + "outstanding_amount": float(total_amount - actual_paid_amount), } - + return render_template( - 'invoices/list.html', - invoices=invoices_pagination.items, - pagination=invoices_pagination, - summary=summary + "invoices/list.html", invoices=invoices_pagination.items, pagination=invoices_pagination, summary=summary ) -@invoices_bp.route('/invoices/create', methods=['GET', 'POST']) +@invoices_bp.route("/invoices/create", methods=["GET", "POST"]) @login_required def create_invoice(): """Create a new invoice - REFACTORED VERSION""" - if request.method == 'POST': + if request.method == "POST": # Get form data - project_id = request.form.get('project_id', type=int) - client_name = request.form.get('client_name', '').strip() - client_email = request.form.get('client_email', '').strip() - client_address = request.form.get('client_address', '').strip() - due_date_str = request.form.get('due_date', '').strip() - tax_rate = request.form.get('tax_rate', '0').strip() - notes = request.form.get('notes', '').strip() - terms = request.form.get('terms', '').strip() - + project_id = request.form.get("project_id", type=int) + client_name = request.form.get("client_name", "").strip() + client_email = request.form.get("client_email", "").strip() + client_address = request.form.get("client_address", "").strip() + due_date_str = request.form.get("due_date", "").strip() + tax_rate = request.form.get("tax_rate", "0").strip() + notes = request.form.get("notes", "").strip() + terms = request.form.get("terms", "").strip() + # Validate required fields if not project_id or not client_name or not due_date_str: - flash('Project, client name, and due date are required', 'error') - return render_template('invoices/create.html') - + flash("Project, client name, and due date are required", "error") + return render_template("invoices/create.html") + try: - due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date() + due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() except ValueError: - flash('Invalid due date format', 'error') - return render_template('invoices/create.html') - + flash("Invalid due date format", "error") + return render_template("invoices/create.html") + try: tax_rate = Decimal(tax_rate) except ValueError: - flash('Invalid tax rate format', 'error') - return render_template('invoices/create.html') - + flash("Invalid tax rate format", "error") + return render_template("invoices/create.html") + # Get project project_repo = ProjectRepository() project = project_repo.get_by_id(project_id) if not project: - flash('Selected project not found', 'error') - return render_template('invoices/create.html') - + flash("Selected project not found", "error") + return render_template("invoices/create.html") + # Generate invoice number invoice_repo = InvoiceRepository() invoice_number = invoice_repo.generate_invoice_number() - + # Track project selected - track_invoice_project_selected(current_user.id, { - "project_id": project_id, - "has_email": bool(client_email), - "has_tax": tax_rate > 0 - }) - + track_invoice_project_selected( + current_user.id, {"project_id": project_id, "has_email": bool(client_email), "has_tax": tax_rate > 0} + ) + # Get currency from settings settings = Settings.get_settings() - currency_code = settings.currency if settings else 'USD' - + currency_code = settings.currency if settings else "USD" + # Create invoice using repository invoice = invoice_repo.create( invoice_number=invoice_number, @@ -173,109 +155,104 @@ def create_invoice(): due_date=due_date, created_by=current_user.id, client_id=project.client_id, - quote_id=project.quote_id if hasattr(project, 'quote_id') else None, + quote_id=project.quote_id if hasattr(project, "quote_id") else None, client_email=client_email, client_address=client_address, tax_rate=tax_rate, notes=notes, terms=terms, currency_code=currency_code, - status=InvoiceStatus.DRAFT.value + status=InvoiceStatus.DRAFT.value, ) - - if not safe_commit('create_invoice', {'project_id': project_id, 'created_by': current_user.id}): - flash('Could not create invoice due to a database error', 'error') - return render_template('invoices/create.html') - + + if not safe_commit("create_invoice", {"project_id": project_id, "created_by": current_user.id}): + flash("Could not create invoice due to a database error", "error") + return render_template("invoices/create.html") + # Track invoice created - track_invoice_generated(current_user.id, { - "invoice_id": invoice.id, - "invoice_number": invoice_number, - "has_tax": float(tax_rate) > 0, - "has_notes": bool(notes) - }) - + track_invoice_generated( + current_user.id, + { + "invoice_id": invoice.id, + "invoice_number": invoice_number, + "has_tax": float(tax_rate) > 0, + "has_notes": bool(notes), + }, + ) + # Emit domain event - emit_event(WebhookEvent.INVOICE_CREATED.value, { - 'invoice_id': invoice.id, - 'project_id': project_id, - 'client_id': project.client_id - }) - - flash(f'Invoice {invoice_number} created successfully', 'success') - return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id)) - + emit_event( + WebhookEvent.INVOICE_CREATED.value, + {"invoice_id": invoice.id, "project_id": project_id, "client_id": project.client_id}, + ) + + flash(f"Invoice {invoice_number} created successfully", "success") + return redirect(url_for("invoices.edit_invoice", invoice_id=invoice.id)) + # GET request - show form project_repo = ProjectRepository() projects = project_repo.get_billable_projects() settings = Settings.get_settings() - default_due_date = (datetime.utcnow() + timedelta(days=30)).strftime('%Y-%m-%d') - + default_due_date = (datetime.utcnow() + timedelta(days=30)).strftime("%Y-%m-%d") + return render_template( - 'invoices/create.html', - projects=projects, - settings=settings, - default_due_date=default_due_date + "invoices/create.html", projects=projects, settings=settings, default_due_date=default_due_date ) -@invoices_bp.route('/invoices//mark-sent', methods=['POST']) +@invoices_bp.route("/invoices//mark-sent", methods=["POST"]) @login_required def mark_invoice_sent(invoice_id): """Mark invoice as sent - REFACTORED VERSION""" # Use service layer service = InvoiceService() result = service.mark_as_sent(invoice_id) - - if result['success']: + + if result["success"]: # Emit domain event - emit_event(WebhookEvent.INVOICE_SENT.value, { - 'invoice_id': invoice_id - }) - - flash(_('Invoice marked as sent'), 'success') + emit_event(WebhookEvent.INVOICE_SENT.value, {"invoice_id": invoice_id}) + + flash(_("Invoice marked as sent"), "success") else: - flash(_(result['message']), 'error') - - return redirect(url_for('invoices.view_invoice', invoice_id=invoice_id)) + flash(_(result["message"]), "error") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) -@invoices_bp.route('/invoices//mark-paid', methods=['POST']) + +@invoices_bp.route("/invoices//mark-paid", methods=["POST"]) @login_required def mark_invoice_paid(invoice_id): """Mark invoice as paid - REFACTORED VERSION""" - payment_date_str = request.form.get('payment_date', '').strip() - payment_method = request.form.get('payment_method', '').strip() - payment_reference = request.form.get('payment_reference', '').strip() - + payment_date_str = request.form.get("payment_date", "").strip() + payment_method = request.form.get("payment_method", "").strip() + payment_reference = request.form.get("payment_reference", "").strip() + payment_date = None if payment_date_str: try: - payment_date = datetime.strptime(payment_date_str, '%Y-%m-%d').date() + payment_date = datetime.strptime(payment_date_str, "%Y-%m-%d").date() except ValueError: payment_date = date.today() else: payment_date = date.today() - + # Use service layer service = InvoiceService() result = service.mark_as_paid( invoice_id=invoice_id, payment_date=payment_date, payment_method=payment_method or None, - payment_reference=payment_reference or None + payment_reference=payment_reference or None, ) - - if result['success']: + + if result["success"]: # Emit domain event - emit_event(WebhookEvent.INVOICE_PAID.value, { - 'invoice_id': invoice_id, - 'payment_date': payment_date.isoformat() - }) - - flash(_('Invoice marked as paid'), 'success') + emit_event( + WebhookEvent.INVOICE_PAID.value, {"invoice_id": invoice_id, "payment_date": payment_date.isoformat()} + ) + + flash(_("Invoice marked as paid"), "success") else: - flash(_(result['message']), 'error') - - return redirect(url_for('invoices.view_invoice', invoice_id=invoice_id)) + flash(_(result["message"]), "error") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) diff --git a/app/routes/kanban.py b/app/routes/kanban.py index d28c3834..dfae676a 100644 --- a/app/routes/kanban.py +++ b/app/routes/kanban.py @@ -6,12 +6,14 @@ from app.utils.db import safe_commit from app.routes.admin import admin_required -kanban_bp = Blueprint('kanban', __name__) -@kanban_bp.route('/kanban') +kanban_bp = Blueprint("kanban", __name__) + + +@kanban_bp.route("/kanban") @login_required def board(): """Kanban board page with optional project filter""" - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) query = Task.query if project_id: query = query.filter_by(project_id=project_id) @@ -33,68 +35,76 @@ def board(): columns = [] # Provide projects for filter dropdown from app.models import Project - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() # No-cache - response = render_template('kanban/board.html', tasks=tasks, kanban_columns=columns, projects=projects, project_id=project_id) + response = render_template( + "kanban/board.html", tasks=tasks, kanban_columns=columns, projects=projects, project_id=project_id + ) resp = make_response(response) - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' - resp.headers['Pragma'] = 'no-cache' - resp.headers['Expires'] = '0' + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" return resp -@kanban_bp.route('/kanban/columns') + +@kanban_bp.route("/kanban/columns") @login_required @admin_required def list_columns(): """List kanban columns for management, optionally filtered by project""" - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) # Force fresh data from database - clear all caches db.session.expire_all() columns = KanbanColumn.get_all_columns(project_id=project_id) - + # Get projects for filter dropdown from app.models import Project - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Prevent browser caching - response = render_template('kanban/columns.html', columns=columns, projects=projects, project_id=project_id) + response = render_template("kanban/columns.html", columns=columns, projects=projects, project_id=project_id) resp = make_response(response) - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' - resp.headers['Pragma'] = 'no-cache' - resp.headers['Expires'] = '0' + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" return resp -@kanban_bp.route('/kanban/columns/create', methods=['GET', 'POST']) + +@kanban_bp.route("/kanban/columns/create", methods=["GET", "POST"]) @login_required @admin_required def create_column(): """Create a new kanban column""" - project_id = request.args.get('project_id', type=int) or request.form.get('project_id', type=int) - - if request.method == 'POST': - key = request.form.get('key', '').strip().lower().replace(' ', '_') - label = request.form.get('label', '').strip() - icon = request.form.get('icon', 'fas fa-circle').strip() - color = request.form.get('color', 'secondary').strip() - is_complete_state = request.form.get('is_complete_state') == 'on' - project_id = request.form.get('project_id', type=int) or None - + project_id = request.args.get("project_id", type=int) or request.form.get("project_id", type=int) + + if request.method == "POST": + key = request.form.get("key", "").strip().lower().replace(" ", "_") + label = request.form.get("label", "").strip() + icon = request.form.get("icon", "fas fa-circle").strip() + color = request.form.get("color", "secondary").strip() + is_complete_state = request.form.get("is_complete_state") == "on" + project_id = request.form.get("project_id", type=int) or None + # Validate required fields if not key or not label: - flash(_('Key and label are required'), 'error') + flash(_("Key and label are required"), "error") from app.models import Project - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template('kanban/create_column.html', projects=projects, project_id=project_id) - + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("kanban/create_column.html", projects=projects, project_id=project_id) + # Check if key already exists for this project (or globally) existing = KanbanColumn.get_column_by_key(key, project_id=project_id) if existing: project_text = f" for this project" if project_id else " globally" - flash(f'A column with key "{key}" already exists{project_text}', 'error') + flash(f'A column with key "{key}" already exists{project_text}', "error") from app.models import Project - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template('kanban/create_column.html', projects=projects, project_id=project_id) - + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("kanban/create_column.html", projects=projects, project_id=project_id) + # Get max position for this project (or globally) and add 1 query = db.session.query(db.func.max(KanbanColumn.position)) if project_id is None: @@ -102,7 +112,7 @@ def create_column(): else: query = query.filter_by(project_id=project_id) max_position = query.scalar() or -1 - + # Create column column = KanbanColumn( key=key, @@ -113,267 +123,291 @@ def create_column(): is_complete_state=is_complete_state, is_system=False, is_active=True, - project_id=project_id + project_id=project_id, ) - + db.session.add(column) - + # Explicitly flush to write to database immediately try: db.session.flush() except Exception as e: db.session.rollback() - flash(f'Could not create column: {str(e)}', 'error') + flash(f"Could not create column: {str(e)}", "error") print(f"[KANBAN] Flush failed: {e}") from app.models import Project - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template('kanban/create_column.html', projects=projects, project_id=project_id) - + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("kanban/create_column.html", projects=projects, project_id=project_id) + # Now commit the transaction - if not safe_commit('create_kanban_column', {'key': key, 'project_id': project_id}): - flash(_('Could not create column due to a database error. Please check server logs.'), 'error') + if not safe_commit("create_kanban_column", {"key": key, "project_id": project_id}): + flash(_("Could not create column due to a database error. Please check server logs."), "error") from app.models import Project - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template('kanban/create_column.html', projects=projects, project_id=project_id) - + + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("kanban/create_column.html", projects=projects, project_id=project_id) + print(f"[KANBAN] Column '{key}' committed to database successfully") - - flash(f'Column "{label}" created successfully', 'success') + + flash(f'Column "{label}" created successfully', "success") # Clear any SQLAlchemy cache to ensure fresh data on next load db.session.expire_all() # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: created column '{key}'") - socketio.emit('kanban_columns_updated', {'action': 'created', 'column_key': key, 'project_id': project_id}, broadcast=True) + socketio.emit( + "kanban_columns_updated", + {"action": "created", "column_key": key, "project_id": project_id}, + broadcast=True, + ) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - - redirect_url = url_for('kanban.list_columns') + + redirect_url = url_for("kanban.list_columns") if project_id: - redirect_url = url_for('kanban.list_columns', project_id=project_id) + redirect_url = url_for("kanban.list_columns", project_id=project_id) return redirect(redirect_url) - + from app.models import Project - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template('kanban/create_column.html', projects=projects, project_id=project_id) -@kanban_bp.route('/kanban/columns//edit', methods=['GET', 'POST']) + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("kanban/create_column.html", projects=projects, project_id=project_id) + + +@kanban_bp.route("/kanban/columns//edit", methods=["GET", "POST"]) @login_required @admin_required def edit_column(column_id): """Edit an existing kanban column""" column = KanbanColumn.query.get_or_404(column_id) - - if request.method == 'POST': - label = request.form.get('label', '').strip() - icon = request.form.get('icon', 'fas fa-circle').strip() - color = request.form.get('color', 'secondary').strip() - is_complete_state = request.form.get('is_complete_state') == 'on' - is_active = request.form.get('is_active') == 'on' - + + if request.method == "POST": + label = request.form.get("label", "").strip() + icon = request.form.get("icon", "fas fa-circle").strip() + color = request.form.get("color", "secondary").strip() + is_complete_state = request.form.get("is_complete_state") == "on" + is_active = request.form.get("is_active") == "on" + # Validate required fields if not label: - flash(_('Label is required'), 'error') - return render_template('kanban/edit_column.html', column=column) - + flash(_("Label is required"), "error") + return render_template("kanban/edit_column.html", column=column) + # Update column column.label = label column.icon = icon column.color = color column.is_complete_state = is_complete_state column.is_active = is_active - + # Explicitly flush to write changes immediately try: db.session.flush() except Exception as e: db.session.rollback() - flash(f'Could not update column: {str(e)}', 'error') + flash(f"Could not update column: {str(e)}", "error") print(f"[KANBAN] Flush failed: {e}") - return render_template('kanban/edit_column.html', column=column) - + return render_template("kanban/edit_column.html", column=column) + # Now commit the transaction - if not safe_commit('edit_kanban_column', {'column_id': column_id}): - flash(_('Could not update column due to a database error. Please check server logs.'), 'error') - return render_template('kanban/edit_column.html', column=column) - + if not safe_commit("edit_kanban_column", {"column_id": column_id}): + flash(_("Could not update column due to a database error. Please check server logs."), "error") + return render_template("kanban/edit_column.html", column=column) + print(f"[KANBAN] Column {column_id} updated and committed to database successfully") - - flash(f'Column "{label}" updated successfully', 'success') + + flash(f'Column "{label}" updated successfully', "success") # Clear any SQLAlchemy cache to ensure fresh data on next load db.session.expire_all() # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: updated column ID {column_id}") - socketio.emit('kanban_columns_updated', {'action': 'updated', 'column_id': column_id, 'project_id': column.project_id}, broadcast=True) + socketio.emit( + "kanban_columns_updated", + {"action": "updated", "column_id": column_id, "project_id": column.project_id}, + broadcast=True, + ) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - - redirect_url = url_for('kanban.list_columns') + + redirect_url = url_for("kanban.list_columns") if column.project_id: - redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + redirect_url = url_for("kanban.list_columns", project_id=column.project_id) return redirect(redirect_url) - - return render_template('kanban/edit_column.html', column=column) -@kanban_bp.route('/kanban/columns//delete', methods=['POST']) + return render_template("kanban/edit_column.html", column=column) + + +@kanban_bp.route("/kanban/columns//delete", methods=["POST"]) @login_required @admin_required def delete_column(column_id): """Delete a kanban column (only if not system and has no tasks)""" column = KanbanColumn.query.get_or_404(column_id) - + # Check if system column if column.is_system: - flash(_('System columns cannot be deleted'), 'error') - redirect_url = url_for('kanban.list_columns') + flash(_("System columns cannot be deleted"), "error") + redirect_url = url_for("kanban.list_columns") if column.project_id: - redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + redirect_url = url_for("kanban.list_columns", project_id=column.project_id) return redirect(redirect_url) - + # Check if column has tasks (filter by project if column is project-specific) task_query = Task.query.filter_by(status=column.key) if column.project_id: task_query = task_query.filter_by(project_id=column.project_id) task_count = task_query.count() if task_count > 0: - flash(f'Cannot delete column with {task_count} task(s). Move or delete tasks first.', 'error') - redirect_url = url_for('kanban.list_columns') + flash(f"Cannot delete column with {task_count} task(s). Move or delete tasks first.", "error") + redirect_url = url_for("kanban.list_columns") if column.project_id: - redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + redirect_url = url_for("kanban.list_columns", project_id=column.project_id) return redirect(redirect_url) - + column_name = column.label project_id = column.project_id db.session.delete(column) - + # Explicitly flush to execute delete immediately try: db.session.flush() except Exception as e: db.session.rollback() - flash(f'Could not delete column: {str(e)}', 'error') + flash(f"Could not delete column: {str(e)}", "error") print(f"[KANBAN] Flush failed: {e}") - redirect_url = url_for('kanban.list_columns') + redirect_url = url_for("kanban.list_columns") if project_id: - redirect_url = url_for('kanban.list_columns', project_id=project_id) + redirect_url = url_for("kanban.list_columns", project_id=project_id) return redirect(redirect_url) - + # Now commit the transaction - if not safe_commit('delete_kanban_column', {'column_id': column_id}): - flash(_('Could not delete column due to a database error. Please check server logs.'), 'error') - redirect_url = url_for('kanban.list_columns') + if not safe_commit("delete_kanban_column", {"column_id": column_id}): + flash(_("Could not delete column due to a database error. Please check server logs."), "error") + redirect_url = url_for("kanban.list_columns") if project_id: - redirect_url = url_for('kanban.list_columns', project_id=project_id) + redirect_url = url_for("kanban.list_columns", project_id=project_id) return redirect(redirect_url) - + print(f"[KANBAN] Column {column_id} deleted and committed to database successfully") - - flash(f'Column "{column_name}" deleted successfully', 'success') + + flash(f'Column "{column_name}" deleted successfully', "success") # Clear any SQLAlchemy cache to ensure fresh data on next load db.session.expire_all() # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: deleted column ID {column_id}") - socketio.emit('kanban_columns_updated', {'action': 'deleted', 'column_id': column_id, 'project_id': project_id}, broadcast=True) + socketio.emit( + "kanban_columns_updated", + {"action": "deleted", "column_id": column_id, "project_id": project_id}, + broadcast=True, + ) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - - redirect_url = url_for('kanban.list_columns') + + redirect_url = url_for("kanban.list_columns") if project_id: - redirect_url = url_for('kanban.list_columns', project_id=project_id) + redirect_url = url_for("kanban.list_columns", project_id=project_id) return redirect(redirect_url) -@kanban_bp.route('/kanban/columns//toggle', methods=['POST']) + +@kanban_bp.route("/kanban/columns//toggle", methods=["POST"]) @login_required @admin_required def toggle_column(column_id): """Toggle column active status""" column = KanbanColumn.query.get_or_404(column_id) - + column.is_active = not column.is_active - + # Explicitly flush to write changes immediately try: db.session.flush() except Exception as e: db.session.rollback() - flash(f'Could not toggle column: {str(e)}', 'error') + flash(f"Could not toggle column: {str(e)}", "error") print(f"[KANBAN] Flush failed: {e}") - return redirect(url_for('kanban.list_columns')) - + return redirect(url_for("kanban.list_columns")) + # Now commit the transaction - if not safe_commit('toggle_kanban_column', {'column_id': column_id}): - flash(_('Could not toggle column due to a database error. Please check server logs.'), 'error') - return redirect(url_for('kanban.list_columns')) - + if not safe_commit("toggle_kanban_column", {"column_id": column_id}): + flash(_("Could not toggle column due to a database error. Please check server logs."), "error") + return redirect(url_for("kanban.list_columns")) + print(f"[KANBAN] Column {column_id} toggled and committed to database successfully") - - status = 'activated' if column.is_active else 'deactivated' - flash(f'Column "{column.label}" {status} successfully', 'success') + + status = "activated" if column.is_active else "deactivated" + flash(f'Column "{column.label}" {status} successfully', "success") # Clear any SQLAlchemy cache to ensure fresh data on next load db.session.expire_all() # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: toggled column ID {column_id}") - socketio.emit('kanban_columns_updated', {'action': 'toggled', 'column_id': column_id, 'project_id': column.project_id}, broadcast=True) + socketio.emit( + "kanban_columns_updated", + {"action": "toggled", "column_id": column_id, "project_id": column.project_id}, + broadcast=True, + ) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - - redirect_url = url_for('kanban.list_columns') + + redirect_url = url_for("kanban.list_columns") if column.project_id: - redirect_url = url_for('kanban.list_columns', project_id=column.project_id) + redirect_url = url_for("kanban.list_columns", project_id=column.project_id) return redirect(redirect_url) -@kanban_bp.route('/api/kanban/columns/reorder', methods=['POST']) + +@kanban_bp.route("/api/kanban/columns/reorder", methods=["POST"]) @login_required @admin_required def reorder_columns(): """Reorder kanban columns via API""" data = request.get_json() - column_ids = data.get('column_ids', []) - project_id = data.get('project_id', None) - + column_ids = data.get("column_ids", []) + project_id = data.get("project_id", None) + if not column_ids: - return jsonify({'error': 'No column IDs provided'}), 400 - + return jsonify({"error": "No column IDs provided"}), 400 + try: # Reorder columns for the specified project (or globally if project_id is None) KanbanColumn.reorder_columns(column_ids, project_id=project_id) - + # Explicitly flush to write changes immediately db.session.flush() - + # Force database commit db.session.commit() - + print(f"[KANBAN] Columns reordered and committed to database successfully") - + # Clear all caches to force fresh reads db.session.expire_all() - + # Notify all connected clients to refresh kanban boards try: print(f"[KANBAN] Emitting kanban_columns_updated event: reordered columns") - socketio.emit('kanban_columns_updated', {'action': 'reordered', 'project_id': project_id}, broadcast=True) + socketio.emit("kanban_columns_updated", {"action": "reordered", "project_id": project_id}, broadcast=True) print(f"[KANBAN] Event emitted successfully") except Exception as e: print(f"[KANBAN] Failed to emit event: {e}") - - return jsonify({'success': True, 'message': 'Columns reordered successfully'}) + + return jsonify({"success": True, "message": "Columns reordered successfully"}) except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@kanban_bp.route('/api/kanban/columns') + +@kanban_bp.route("/api/kanban/columns") @login_required def api_list_columns(): """API endpoint to get active kanban columns, optionally filtered by project""" - project_id = request.args.get('project_id', type=int) + project_id = request.args.get("project_id", type=int) # Force fresh data - no caching db.session.expire_all() if KanbanColumn: @@ -388,13 +422,12 @@ def api_list_columns(): columns = KanbanColumn.get_active_columns(project_id=None) else: columns = [] - response = jsonify({'columns': [col.to_dict() for col in columns]}) + response = jsonify({"columns": [col.to_dict() for col in columns]}) # Add no-cache headers to avoid SW/browser caching try: - response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0' - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" except Exception: pass return response - diff --git a/app/routes/kiosk.py b/app/routes/kiosk.py index ebc19a04..5e495313 100644 --- a/app/routes/kiosk.py +++ b/app/routes/kiosk.py @@ -1,561 +1,565 @@ """Kiosk Mode Routes - Inventory and Barcode Scanning""" + from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app, session from flask_babel import gettext as _ from flask_login import login_required, current_user, login_user, logout_user from app import db, log_event -from app.models import ( - User, StockItem, Warehouse, WarehouseStock, StockMovement, - Project, TimeEntry, Task, Settings -) +from app.models import User, StockItem, Warehouse, WarehouseStock, StockMovement, Project, TimeEntry, Task, Settings from datetime import datetime from decimal import Decimal, InvalidOperation from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required from sqlalchemy import func, or_ -kiosk_bp = Blueprint('kiosk', __name__) +kiosk_bp = Blueprint("kiosk", __name__) -@kiosk_bp.route('/kiosk') +@kiosk_bp.route("/kiosk") @login_required def kiosk_dashboard(): """Main kiosk interface""" # Check if kiosk mode is enabled (handle missing columns gracefully) try: settings = Settings.get_settings() - kiosk_enabled = getattr(settings, 'kiosk_mode_enabled', False) + kiosk_enabled = getattr(settings, "kiosk_mode_enabled", False) except Exception: # Migration not run yet, default to False kiosk_enabled = False - + if not kiosk_enabled: - flash(_('Kiosk mode is not enabled. Please contact an administrator.'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("Kiosk mode is not enabled. Please contact an administrator."), "error") + return redirect(url_for("main.dashboard")) + # Get active timer active_timer = current_user.active_timer - + # Get default warehouse (from session or first active) default_warehouse = None - default_warehouse_id = session.get('kiosk_default_warehouse_id') + default_warehouse_id = session.get("kiosk_default_warehouse_id") if default_warehouse_id: default_warehouse = Warehouse.query.get(default_warehouse_id) - + if not default_warehouse: default_warehouse = Warehouse.query.filter_by(is_active=True).first() - + # Get active warehouses warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - + # Get active projects for timer - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Get recent items (last 10 used by this user - stored in session) recent_items = [] - recent_item_ids = session.get('kiosk_recent_items', []) + recent_item_ids = session.get("kiosk_recent_items", []) if recent_item_ids: try: recent_items = StockItem.query.filter( - StockItem.id.in_(recent_item_ids[:10]), - StockItem.is_active == True + StockItem.id.in_(recent_item_ids[:10]), StockItem.is_active == True ).all() except Exception: pass - - return render_template('kiosk/dashboard.html', - active_timer=active_timer, - default_warehouse=default_warehouse, - warehouses=warehouses, - active_projects=active_projects, - recent_items=recent_items) + + return render_template( + "kiosk/dashboard.html", + active_timer=active_timer, + default_warehouse=default_warehouse, + warehouses=warehouses, + active_projects=active_projects, + recent_items=recent_items, + ) -@kiosk_bp.route('/kiosk/login', methods=['GET', 'POST']) +@kiosk_bp.route("/kiosk/login", methods=["GET", "POST"]) def kiosk_login(): """Quick login for kiosk mode""" # Check if kiosk mode is enabled (handle missing columns gracefully) try: settings = Settings.get_settings() - kiosk_enabled = getattr(settings, 'kiosk_mode_enabled', False) + kiosk_enabled = getattr(settings, "kiosk_mode_enabled", False) except Exception: # Migration not run yet, default to False kiosk_enabled = False - + if not kiosk_enabled: - flash(_('Kiosk mode is not enabled. Please contact an administrator.'), 'error') - return redirect(url_for('auth.login')) - + flash(_("Kiosk mode is not enabled. Please contact an administrator."), "error") + return redirect(url_for("auth.login")) + if current_user.is_authenticated: - return redirect(url_for('kiosk.kiosk_dashboard')) - + return redirect(url_for("kiosk.kiosk_dashboard")) + # Get authentication method try: from app.config import Config - auth_method = (getattr(Config, 'AUTH_METHOD', 'local') or 'local').strip().lower() + + auth_method = (getattr(Config, "AUTH_METHOD", "local") or "local").strip().lower() except Exception: - auth_method = 'local' - + auth_method = "local" + # Determine if password authentication is required (kiosk doesn't support OIDC) - requires_password = auth_method in ('local', 'both') - - if request.method == 'POST': - username = request.form.get('username', '').strip() - password = request.form.get('password', '') - + requires_password = auth_method in ("local", "both") + + if request.method == "POST": + username = request.form.get("username", "").strip() + password = request.form.get("password", "") + if not username: - flash(_('Username is required'), 'error') - return redirect(url_for('kiosk.kiosk_login')) - + flash(_("Username is required"), "error") + return redirect(url_for("kiosk.kiosk_login")) + user = User.query.filter_by(username=username, is_active=True).first() if not user: - flash(_('Invalid username or password'), 'error') - return redirect(url_for('kiosk.kiosk_login')) - + flash(_("Invalid username or password"), "error") + return redirect(url_for("kiosk.kiosk_login")) + # Handle password authentication based on mode if requires_password: # Password authentication is required if user.has_password: # User has password set - verify it if not password: - flash(_('Password is required'), 'error') - return redirect(url_for('kiosk.kiosk_login')) - + flash(_("Password is required"), "error") + return redirect(url_for("kiosk.kiosk_login")) + if not user.check_password(password): - flash(_('Invalid username or password'), 'error') - return redirect(url_for('kiosk.kiosk_login')) + flash(_("Invalid username or password"), "error") + return redirect(url_for("kiosk.kiosk_login")) else: # User doesn't have password set - deny access in kiosk mode - flash(_('No password is set for this account. Please set a password in your profile first.'), 'error') - return redirect(url_for('kiosk.kiosk_login')) - + flash(_("No password is set for this account. Please set a password in your profile first."), "error") + return redirect(url_for("kiosk.kiosk_login")) + # For 'none' mode, no password check needed - just log in login_user(user, remember=False) # Don't remember in kiosk mode log_event("auth.kiosk_login", user_id=user.id) - return redirect(url_for('kiosk.kiosk_dashboard')) - + return redirect(url_for("kiosk.kiosk_dashboard")) + # Get list of active users for quick selection users = User.query.filter_by(is_active=True).order_by(User.username).all() - return render_template('kiosk/login.html', users=users, requires_password=requires_password) + return render_template("kiosk/login.html", users=users, requires_password=requires_password) -@kiosk_bp.route('/kiosk/logout', methods=['GET', 'POST']) +@kiosk_bp.route("/kiosk/logout", methods=["GET", "POST"]) @login_required def kiosk_logout(): """Logout from kiosk mode""" user_id = current_user.id username = current_user.username - + # Clear kiosk-specific session data - session.pop('kiosk_recent_items', None) - session.pop('kiosk_default_warehouse_id', None) - + session.pop("kiosk_recent_items", None) + session.pop("kiosk_default_warehouse_id", None) + # Logout user logout_user() - + # Ensure session keys are cleared for compatibility try: - session.pop('_user_id', None) - session.pop('user_id', None) + session.pop("_user_id", None) + session.pop("user_id", None) except Exception: pass - + log_event("auth.kiosk_logout", user_id=user_id) - flash(_('You have been logged out'), 'success') - return redirect(url_for('kiosk.kiosk_login')) + flash(_("You have been logged out"), "success") + return redirect(url_for("kiosk.kiosk_login")) -@kiosk_bp.route('/api/kiosk/barcode-lookup', methods=['POST']) +@kiosk_bp.route("/api/kiosk/barcode-lookup", methods=["POST"]) @login_required def barcode_lookup(): """Look up stock item by barcode or SKU""" data = request.get_json() or {} - barcode = data.get('barcode', '').strip() - + barcode = data.get("barcode", "").strip() + if not barcode: - return jsonify({'error': 'Barcode required'}), 400 - + return jsonify({"error": "Barcode required"}), 400 + # Search by barcode first item = StockItem.query.filter_by(barcode=barcode, is_active=True).first() - + # If not found, try SKU (case-insensitive) if not item: - item = StockItem.query.filter( - func.upper(StockItem.sku) == barcode.upper(), - StockItem.is_active == True - ).first() - + item = StockItem.query.filter(func.upper(StockItem.sku) == barcode.upper(), StockItem.is_active == True).first() + if not item: - return jsonify({'error': 'Item not found'}), 404 - + return jsonify({"error": "Item not found"}), 404 + # Get stock levels across warehouses - stock_levels = WarehouseStock.query.filter_by( - stock_item_id=item.id - ).join(Warehouse).filter(Warehouse.is_active == True).all() - + stock_levels = ( + WarehouseStock.query.filter_by(stock_item_id=item.id).join(Warehouse).filter(Warehouse.is_active == True).all() + ) + # Update recent items in session try: - recent_item_ids = session.get('kiosk_recent_items', []) - + recent_item_ids = session.get("kiosk_recent_items", []) + # Add to front, remove duplicates, limit to 20 if item.id in recent_item_ids: recent_item_ids.remove(item.id) recent_item_ids.insert(0, item.id) recent_item_ids = recent_item_ids[:20] - - session['kiosk_recent_items'] = recent_item_ids + + session["kiosk_recent_items"] = recent_item_ids session.permanent = True except Exception as e: current_app.logger.warning("Failed to update recent items: %s", e) - - return jsonify({ - 'item': { - 'id': item.id, - 'sku': item.sku, - 'name': item.name, - 'barcode': item.barcode, - 'unit': item.unit, - 'description': item.description, - 'category': item.category, - 'image_url': item.image_url, - 'is_trackable': item.is_trackable - }, - 'stock_levels': [{ - 'warehouse_id': stock.warehouse_id, - 'warehouse_name': stock.warehouse.name, - 'warehouse_code': stock.warehouse.code, - 'quantity_on_hand': float(stock.quantity_on_hand), - 'quantity_available': float(stock.quantity_available), - 'quantity_reserved': float(stock.quantity_reserved), - 'location': stock.location - } for stock in stock_levels] - }) - - -@kiosk_bp.route('/api/kiosk/adjust-stock', methods=['POST']) + + return jsonify( + { + "item": { + "id": item.id, + "sku": item.sku, + "name": item.name, + "barcode": item.barcode, + "unit": item.unit, + "description": item.description, + "category": item.category, + "image_url": item.image_url, + "is_trackable": item.is_trackable, + }, + "stock_levels": [ + { + "warehouse_id": stock.warehouse_id, + "warehouse_name": stock.warehouse.name, + "warehouse_code": stock.warehouse.code, + "quantity_on_hand": float(stock.quantity_on_hand), + "quantity_available": float(stock.quantity_available), + "quantity_reserved": float(stock.quantity_reserved), + "location": stock.location, + } + for stock in stock_levels + ], + } + ) + + +@kiosk_bp.route("/api/kiosk/adjust-stock", methods=["POST"]) @login_required def adjust_stock(): """Quick stock adjustment from kiosk""" data = request.get_json() or {} - + try: - stock_item_id = int(data.get('stock_item_id', 0)) - warehouse_id = int(data.get('warehouse_id', 0)) - quantity = Decimal(str(data.get('quantity', 0))) - reason = data.get('reason', 'Kiosk adjustment').strip() or 'Kiosk adjustment' - notes = data.get('notes', '').strip() or None + stock_item_id = int(data.get("stock_item_id", 0)) + warehouse_id = int(data.get("warehouse_id", 0)) + quantity = Decimal(str(data.get("quantity", 0))) + reason = data.get("reason", "Kiosk adjustment").strip() or "Kiosk adjustment" + notes = data.get("notes", "").strip() or None except (ValueError, InvalidOperation, TypeError) as e: - return jsonify({'error': f'Invalid input: {str(e)}'}), 400 - + return jsonify({"error": f"Invalid input: {str(e)}"}), 400 + if not stock_item_id or not warehouse_id: - return jsonify({'error': 'Item and warehouse required'}), 400 - + return jsonify({"error": "Item and warehouse required"}), 400 + # Validate quantity is not zero if quantity == 0: - return jsonify({'error': 'Quantity cannot be zero'}), 400 - + return jsonify({"error": "Quantity cannot be zero"}), 400 + # Validate quantity is reasonable (prevent accidental huge adjustments) if abs(quantity) > 1000000: - return jsonify({'error': 'Quantity is too large. Please contact an administrator.'}), 400 - + return jsonify({"error": "Quantity is too large. Please contact an administrator."}), 400 + # Verify item exists and is active item = StockItem.query.get(stock_item_id) if not item or not item.is_active: - return jsonify({'error': 'Item not found or inactive'}), 404 - + return jsonify({"error": "Item not found or inactive"}), 404 + # Verify warehouse exists and is active warehouse = Warehouse.query.get(warehouse_id) if not warehouse or not warehouse.is_active: - return jsonify({'error': 'Warehouse not found or inactive'}), 404 - + return jsonify({"error": "Warehouse not found or inactive"}), 404 + # Check permissions from app.utils.permissions import has_permission - if not has_permission(current_user, 'manage_stock_movements'): - return jsonify({'error': 'Permission denied'}), 403 - + + if not has_permission(current_user, "manage_stock_movements"): + return jsonify({"error": "Permission denied"}), 403 + # Record movement try: movement, updated_stock = StockMovement.record_movement( - movement_type='adjustment', + movement_type="adjustment", stock_item_id=stock_item_id, warehouse_id=warehouse_id, quantity=quantity, moved_by=current_user.id, reason=reason, notes=notes, - update_stock=True + update_stock=True, ) - + db.session.commit() - - log_event('stock_movement.kiosk_adjustment', { - 'movement_id': movement.id, - 'stock_item_id': stock_item_id, - 'warehouse_id': warehouse_id, - 'quantity': float(quantity) - }) - - return jsonify({ - 'success': True, - 'movement_id': movement.id, - 'new_quantity': float(updated_stock.quantity_on_hand), - 'message': _('Stock adjustment recorded successfully') - }) + + log_event( + "stock_movement.kiosk_adjustment", + { + "movement_id": movement.id, + "stock_item_id": stock_item_id, + "warehouse_id": warehouse_id, + "quantity": float(quantity), + }, + ) + + return jsonify( + { + "success": True, + "movement_id": movement.id, + "new_quantity": float(updated_stock.quantity_on_hand), + "message": _("Stock adjustment recorded successfully"), + } + ) except Exception as e: db.session.rollback() current_app.logger.exception("Error recording stock adjustment: %s", e) - return jsonify({'error': f'Error recording adjustment: {str(e)}'}), 500 + return jsonify({"error": f"Error recording adjustment: {str(e)}"}), 500 -@kiosk_bp.route('/api/kiosk/transfer-stock', methods=['POST']) +@kiosk_bp.route("/api/kiosk/transfer-stock", methods=["POST"]) @login_required def transfer_stock(): """Transfer stock between warehouses""" data = request.get_json() or {} - + try: - stock_item_id = int(data.get('stock_item_id')) - from_warehouse_id = int(data.get('from_warehouse_id')) - to_warehouse_id = int(data.get('to_warehouse_id')) - quantity = Decimal(str(data.get('quantity', 0))) - notes = data.get('notes', '').strip() or None + stock_item_id = int(data.get("stock_item_id")) + from_warehouse_id = int(data.get("from_warehouse_id")) + to_warehouse_id = int(data.get("to_warehouse_id")) + quantity = Decimal(str(data.get("quantity", 0))) + notes = data.get("notes", "").strip() or None except (ValueError, InvalidOperation, TypeError) as e: - return jsonify({'error': f'Invalid input: {str(e)}'}), 400 - + return jsonify({"error": f"Invalid input: {str(e)}"}), 400 + if not all([stock_item_id, from_warehouse_id, to_warehouse_id]): - return jsonify({'error': 'Item, source warehouse, and destination warehouse required'}), 400 - + return jsonify({"error": "Item, source warehouse, and destination warehouse required"}), 400 + if from_warehouse_id == to_warehouse_id: - return jsonify({'error': 'Source and destination warehouses must be different'}), 400 - + return jsonify({"error": "Source and destination warehouses must be different"}), 400 + if quantity <= 0: - return jsonify({'error': 'Quantity must be positive'}), 400 - + return jsonify({"error": "Quantity must be positive"}), 400 + # Validate quantity is reasonable if quantity > 1000000: - return jsonify({'error': 'Quantity is too large. Please contact an administrator.'}), 400 - + return jsonify({"error": "Quantity is too large. Please contact an administrator."}), 400 + # Verify item exists item = StockItem.query.get(stock_item_id) if not item or not item.is_active: - return jsonify({'error': 'Item not found or inactive'}), 404 - + return jsonify({"error": "Item not found or inactive"}), 404 + # Verify warehouses exist from_warehouse = Warehouse.query.get(from_warehouse_id) to_warehouse = Warehouse.query.get(to_warehouse_id) if not from_warehouse or not from_warehouse.is_active: - return jsonify({'error': 'Source warehouse not found or inactive'}), 404 + return jsonify({"error": "Source warehouse not found or inactive"}), 404 if not to_warehouse or not to_warehouse.is_active: - return jsonify({'error': 'Destination warehouse not found or inactive'}), 404 - + return jsonify({"error": "Destination warehouse not found or inactive"}), 404 + # Check permissions from app.utils.permissions import has_permission - if not has_permission(current_user, 'transfer_stock'): - return jsonify({'error': 'Permission denied'}), 403 - + + if not has_permission(current_user, "transfer_stock"): + return jsonify({"error": "Permission denied"}), 403 + # Check available stock - from_stock = WarehouseStock.query.filter_by( - warehouse_id=from_warehouse_id, - stock_item_id=stock_item_id - ).first() - + from_stock = WarehouseStock.query.filter_by(warehouse_id=from_warehouse_id, stock_item_id=stock_item_id).first() + if not from_stock or from_stock.quantity_available < quantity: - return jsonify({'error': 'Insufficient stock available'}), 400 - + return jsonify({"error": "Insufficient stock available"}), 400 + # Create outbound movement try: out_movement, out_stock = StockMovement.record_movement( - movement_type='transfer', + movement_type="transfer", stock_item_id=stock_item_id, warehouse_id=from_warehouse_id, quantity=-quantity, # Negative for removal moved_by=current_user.id, - reason='Transfer out', + reason="Transfer out", notes=notes, - update_stock=True + update_stock=True, ) - + # Create inbound movement in_movement, in_stock = StockMovement.record_movement( - movement_type='transfer', + movement_type="transfer", stock_item_id=stock_item_id, warehouse_id=to_warehouse_id, quantity=quantity, # Positive for addition moved_by=current_user.id, - reason='Transfer in', + reason="Transfer in", notes=notes, - update_stock=True + update_stock=True, ) - + db.session.commit() - - log_event('stock_movement.kiosk_transfer', { - 'movement_id': out_movement.id, - 'stock_item_id': stock_item_id, - 'from_warehouse_id': from_warehouse_id, - 'to_warehouse_id': to_warehouse_id, - 'quantity': float(quantity) - }) - - return jsonify({ - 'success': True, - 'from_quantity': float(out_stock.quantity_on_hand), - 'to_quantity': float(in_stock.quantity_on_hand), - 'message': _('Stock transfer completed successfully') - }) + + log_event( + "stock_movement.kiosk_transfer", + { + "movement_id": out_movement.id, + "stock_item_id": stock_item_id, + "from_warehouse_id": from_warehouse_id, + "to_warehouse_id": to_warehouse_id, + "quantity": float(quantity), + }, + ) + + return jsonify( + { + "success": True, + "from_quantity": float(out_stock.quantity_on_hand), + "to_quantity": float(in_stock.quantity_on_hand), + "message": _("Stock transfer completed successfully"), + } + ) except Exception as e: db.session.rollback() current_app.logger.exception("Error recording stock transfer: %s", e) - return jsonify({'error': f'Error recording transfer: {str(e)}'}), 500 + return jsonify({"error": f"Error recording transfer: {str(e)}"}), 500 -@kiosk_bp.route('/api/kiosk/start-timer', methods=['POST']) +@kiosk_bp.route("/api/kiosk/start-timer", methods=["POST"]) @login_required def kiosk_start_timer(): """Start timer from kiosk interface""" data = request.get_json() or {} - + try: - project_id = int(data.get('project_id', 0)) if data.get('project_id') else None - task_id = int(data.get('task_id')) if data.get('task_id') else None - notes = data.get('notes', '').strip() or None + project_id = int(data.get("project_id", 0)) if data.get("project_id") else None + task_id = int(data.get("task_id")) if data.get("task_id") else None + notes = data.get("notes", "").strip() or None except (ValueError, TypeError) as e: - return jsonify({'error': f'Invalid input: {str(e)}'}), 400 - + return jsonify({"error": f"Invalid input: {str(e)}"}), 400 + if not project_id: - return jsonify({'error': 'Project is required'}), 400 - + return jsonify({"error": "Project is required"}), 400 + # Check if project exists and is active project = Project.query.get(project_id) - if not project or project.status != 'active': - return jsonify({'error': 'Invalid or inactive project'}), 400 - + if not project or project.status != "active": + return jsonify({"error": "Invalid or inactive project"}), 400 + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - return jsonify({'error': 'You already have an active timer'}), 400 - + return jsonify({"error": "You already have an active timer"}), 400 + # Validate task if provided if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - return jsonify({'error': 'Invalid task for selected project'}), 400 + return jsonify({"error": "Invalid task for selected project"}), 400 else: task = None - + # Create new timer try: from app.models.time_entry import local_now + new_timer = TimeEntry( user_id=current_user.id, project_id=project_id, task_id=task.id if task else None, start_time=local_now(), notes=notes, - source='auto' + source="auto", ) - + db.session.add(new_timer) db.session.commit() - + log_event("timer.started", user_id=current_user.id, project_id=project_id, task_id=task_id) - - return jsonify({ - 'success': True, - 'timer_id': new_timer.id, - 'message': _('Timer started successfully') - }) + + return jsonify({"success": True, "timer_id": new_timer.id, "message": _("Timer started successfully")}) except Exception as e: db.session.rollback() current_app.logger.exception("Error starting timer: %s", e) - return jsonify({'error': f'Error starting timer: {str(e)}'}), 500 + return jsonify({"error": f"Error starting timer: {str(e)}"}), 500 -@kiosk_bp.route('/api/kiosk/stop-timer', methods=['POST']) +@kiosk_bp.route("/api/kiosk/stop-timer", methods=["POST"]) @login_required def kiosk_stop_timer(): """Stop timer from kiosk interface""" active_timer = current_user.active_timer - + if not active_timer: - return jsonify({'error': 'No active timer'}), 400 - + return jsonify({"error": "No active timer"}), 400 + try: from app.models.time_entry import local_now + active_timer.end_time = local_now() db.session.commit() - + log_event("timer.stopped", user_id=current_user.id, timer_id=active_timer.id) - - return jsonify({ - 'success': True, - 'message': _('Timer stopped successfully') - }) + + return jsonify({"success": True, "message": _("Timer stopped successfully")}) except Exception as e: db.session.rollback() current_app.logger.exception("Error stopping timer: %s", e) - return jsonify({'error': f'Error stopping timer: {str(e)}'}), 500 + return jsonify({"error": f"Error stopping timer: {str(e)}"}), 500 -@kiosk_bp.route('/api/kiosk/timer-status', methods=['GET']) +@kiosk_bp.route("/api/kiosk/timer-status", methods=["GET"]) @login_required def kiosk_timer_status(): """Get current timer status""" active_timer = current_user.active_timer - + if not active_timer: - return jsonify({ - 'active': False, - 'timer': None - }) - - return jsonify({ - 'active': True, - 'timer': { - 'id': active_timer.id, - 'project_id': active_timer.project_id, - 'project_name': active_timer.project.name if active_timer.project else None, - 'task_id': active_timer.task_id, - 'task_name': active_timer.task.name if active_timer.task else None, - 'start_time': active_timer.start_time.isoformat() if active_timer.start_time else None, - 'duration_formatted': active_timer.duration_formatted if hasattr(active_timer, 'duration_formatted') else None + return jsonify({"active": False, "timer": None}) + + return jsonify( + { + "active": True, + "timer": { + "id": active_timer.id, + "project_id": active_timer.project_id, + "project_name": active_timer.project.name if active_timer.project else None, + "task_id": active_timer.task_id, + "task_name": active_timer.task.name if active_timer.task else None, + "start_time": active_timer.start_time.isoformat() if active_timer.start_time else None, + "duration_formatted": ( + active_timer.duration_formatted if hasattr(active_timer, "duration_formatted") else None + ), + }, } - }) + ) -@kiosk_bp.route('/api/kiosk/warehouses', methods=['GET']) +@kiosk_bp.route("/api/kiosk/warehouses", methods=["GET"]) @login_required def kiosk_warehouses(): """Get list of active warehouses""" warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - - return jsonify({ - 'warehouses': [{ - 'id': w.id, - 'name': w.name, - 'code': w.code - } for w in warehouses] - }) + + return jsonify({"warehouses": [{"id": w.id, "name": w.name, "code": w.code} for w in warehouses]}) -@kiosk_bp.route('/api/kiosk/projects', methods=['GET']) +@kiosk_bp.route("/api/kiosk/projects", methods=["GET"]) @login_required def kiosk_projects(): """Get list of active projects for timer""" try: from app.models import Client from sqlalchemy.orm import joinedload - + # Query projects with client relationship eager loaded # Note: Client model uses backref='client_obj', not 'client' - projects = Project.query.options( - joinedload(Project.client_obj) - ).filter_by(status='active').order_by(Project.name).all() - + projects = ( + Project.query.options(joinedload(Project.client_obj)) + .filter_by(status="active") + .order_by(Project.name) + .all() + ) + projects_data = [] for p in projects: try: # Access client via client_obj backref (defined in Client model) - if hasattr(p, 'client_obj') and p.client_obj: + if hasattr(p, "client_obj") and p.client_obj: client_name = p.client_obj.name elif p.client_id: # Fallback: query client directly if relationship not loaded @@ -564,40 +568,30 @@ def kiosk_projects(): else: client_name = None except (AttributeError, Exception) as e: - current_app.logger.warning(f'Error accessing client for project {p.id}: {str(e)}') + current_app.logger.warning(f"Error accessing client for project {p.id}: {str(e)}") client_name = None - - projects_data.append({ - 'id': p.id, - 'name': p.name, - 'client_name': client_name - }) - - return jsonify({ - 'projects': projects_data - }) + + projects_data.append({"id": p.id, "name": p.name, "client_name": client_name}) + + return jsonify({"projects": projects_data}) except Exception as e: import traceback - current_app.logger.error(f'Error fetching kiosk projects: {str(e)}\n{traceback.format_exc()}') - return jsonify({ - 'error': 'Failed to fetch projects', - 'projects': [] - }), 500 + + current_app.logger.error(f"Error fetching kiosk projects: {str(e)}\n{traceback.format_exc()}") + return jsonify({"error": "Failed to fetch projects", "projects": []}), 500 -@kiosk_bp.route('/api/kiosk/settings', methods=['GET']) +@kiosk_bp.route("/api/kiosk/settings", methods=["GET"]) @login_required def kiosk_settings_api(): """Get kiosk settings for frontend""" try: settings = Settings.get_settings() - return jsonify({ - 'kiosk_allow_camera_scanning': getattr(settings, 'kiosk_allow_camera_scanning', True), - 'kiosk_auto_logout_minutes': getattr(settings, 'kiosk_auto_logout_minutes', 15) - }) + return jsonify( + { + "kiosk_allow_camera_scanning": getattr(settings, "kiosk_allow_camera_scanning", True), + "kiosk_auto_logout_minutes": getattr(settings, "kiosk_auto_logout_minutes", 15), + } + ) except Exception: - return jsonify({ - 'kiosk_allow_camera_scanning': True, - 'kiosk_auto_logout_minutes': 15 - }) - + return jsonify({"kiosk_allow_camera_scanning": True, "kiosk_auto_logout_minutes": 15}) diff --git a/app/routes/leads.py b/app/routes/leads.py index eb4b4f8c..af5d0fba 100644 --- a/app/routes/leads.py +++ b/app/routes/leads.py @@ -1,4 +1,5 @@ """Routes for lead management""" + from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user @@ -9,37 +10,38 @@ from datetime import datetime from decimal import Decimal, InvalidOperation -leads_bp = Blueprint('leads', __name__) +leads_bp = Blueprint("leads", __name__) # Lead statuses -LEAD_STATUSES = ['new', 'contacted', 'qualified', 'converted', 'lost'] +LEAD_STATUSES = ["new", "contacted", "qualified", "converted", "lost"] + -@leads_bp.route('/leads') +@leads_bp.route("/leads") @login_required def list_leads(): """List all leads""" - status = request.args.get('status', '') - source = request.args.get('source', '') - owner_id = request.args.get('owner', '') - search = request.args.get('search', '').strip() - + status = request.args.get("status", "") + source = request.args.get("source", "") + owner_id = request.args.get("owner", "") + search = request.args.get("search", "").strip() + query = Lead.query - + if status: query = query.filter_by(status=status) else: # Default to active leads (not converted or lost) - query = query.filter(~Lead.status.in_(['converted', 'lost'])) - + query = query.filter(~Lead.status.in_(["converted", "lost"])) + if source: query = query.filter_by(source=source) - + if owner_id: try: query = query.filter_by(owner_id=int(owner_id)) except (ValueError, TypeError): pass - + if search: like = f"%{search}%" query = query.filter( @@ -47,146 +49,155 @@ def list_leads(): Lead.first_name.ilike(like), Lead.last_name.ilike(like), Lead.company_name.ilike(like), - Lead.email.ilike(like) + Lead.email.ilike(like), ) ) - + leads = query.order_by(Lead.score.desc(), Lead.created_at.desc()).all() - - return render_template('leads/list.html', - leads=leads, - lead_statuses=LEAD_STATUSES, - status=status, - source=source, - owner_id=owner_id, - search=search) - -@leads_bp.route('/leads/create', methods=['GET', 'POST']) + + return render_template( + "leads/list.html", + leads=leads, + lead_statuses=LEAD_STATUSES, + status=status, + source=source, + owner_id=owner_id, + search=search, + ) + + +@leads_bp.route("/leads/create", methods=["GET", "POST"]) @login_required def create_lead(): """Create a new lead""" - if request.method == 'POST': + if request.method == "POST": try: # Parse estimated value - value_str = request.form.get('estimated_value', '').strip() + value_str = request.form.get("estimated_value", "").strip() estimated_value = None if value_str: try: estimated_value = Decimal(value_str) except (InvalidOperation, ValueError): pass - + lead = Lead( - first_name=request.form.get('first_name', '').strip(), - last_name=request.form.get('last_name', '').strip(), + first_name=request.form.get("first_name", "").strip(), + last_name=request.form.get("last_name", "").strip(), created_by=current_user.id, - company_name=request.form.get('company_name', '').strip() or None, - email=request.form.get('email', '').strip() or None, - phone=request.form.get('phone', '').strip() or None, - title=request.form.get('title', '').strip() or None, - source=request.form.get('source', '').strip() or None, - status=request.form.get('status', 'new').strip(), - score=int(request.form.get('score', 0)), + company_name=request.form.get("company_name", "").strip() or None, + email=request.form.get("email", "").strip() or None, + phone=request.form.get("phone", "").strip() or None, + title=request.form.get("title", "").strip() or None, + source=request.form.get("source", "").strip() or None, + status=request.form.get("status", "new").strip(), + score=int(request.form.get("score", 0)), estimated_value=estimated_value, - currency_code=request.form.get('currency_code', 'EUR').strip(), - notes=request.form.get('notes', '').strip() or None, - tags=request.form.get('tags', '').strip() or None, - owner_id=int(request.form.get('owner_id')) if request.form.get('owner_id') else current_user.id + currency_code=request.form.get("currency_code", "EUR").strip(), + notes=request.form.get("notes", "").strip() or None, + tags=request.form.get("tags", "").strip() or None, + owner_id=int(request.form.get("owner_id")) if request.form.get("owner_id") else current_user.id, ) - + db.session.add(lead) - + if safe_commit(): - flash(_('Lead created successfully'), 'success') - return redirect(url_for('leads.view_lead', lead_id=lead.id)) + flash(_("Lead created successfully"), "success") + return redirect(url_for("leads.view_lead", lead_id=lead.id)) except Exception as e: db.session.rollback() - flash(_('Error creating lead: %(error)s', error=str(e)), 'error') - - return render_template('leads/form.html', lead=None, lead_statuses=LEAD_STATUSES) + flash(_("Error creating lead: %(error)s", error=str(e)), "error") + + return render_template("leads/form.html", lead=None, lead_statuses=LEAD_STATUSES) -@leads_bp.route('/leads/') + +@leads_bp.route("/leads/") @login_required def view_lead(lead_id): """View a lead""" lead = Lead.query.get_or_404(lead_id) - activities = LeadActivity.query.filter_by(lead_id=lead_id).order_by(LeadActivity.activity_date.desc()).limit(50).all() - return render_template('leads/view.html', lead=lead, activities=activities) + activities = ( + LeadActivity.query.filter_by(lead_id=lead_id).order_by(LeadActivity.activity_date.desc()).limit(50).all() + ) + return render_template("leads/view.html", lead=lead, activities=activities) + -@leads_bp.route('/leads//edit', methods=['GET', 'POST']) +@leads_bp.route("/leads//edit", methods=["GET", "POST"]) @login_required def edit_lead(lead_id): """Edit a lead""" lead = Lead.query.get_or_404(lead_id) - - if request.method == 'POST': + + if request.method == "POST": try: # Parse estimated value - value_str = request.form.get('estimated_value', '').strip() + value_str = request.form.get("estimated_value", "").strip() estimated_value = None if value_str: try: estimated_value = Decimal(value_str) except (InvalidOperation, ValueError): pass - - lead.first_name = request.form.get('first_name', '').strip() - lead.last_name = request.form.get('last_name', '').strip() - lead.company_name = request.form.get('company_name', '').strip() or None - lead.email = request.form.get('email', '').strip() or None - lead.phone = request.form.get('phone', '').strip() or None - lead.title = request.form.get('title', '').strip() or None - lead.source = request.form.get('source', '').strip() or None - lead.status = request.form.get('status', 'new').strip() - lead.score = int(request.form.get('score', 0)) + + lead.first_name = request.form.get("first_name", "").strip() + lead.last_name = request.form.get("last_name", "").strip() + lead.company_name = request.form.get("company_name", "").strip() or None + lead.email = request.form.get("email", "").strip() or None + lead.phone = request.form.get("phone", "").strip() or None + lead.title = request.form.get("title", "").strip() or None + lead.source = request.form.get("source", "").strip() or None + lead.status = request.form.get("status", "new").strip() + lead.score = int(request.form.get("score", 0)) lead.estimated_value = estimated_value - lead.currency_code = request.form.get('currency_code', 'EUR').strip() - lead.notes = request.form.get('notes', '').strip() or None - lead.tags = request.form.get('tags', '').strip() or None - lead.owner_id = int(request.form.get('owner_id')) if request.form.get('owner_id') else current_user.id + lead.currency_code = request.form.get("currency_code", "EUR").strip() + lead.notes = request.form.get("notes", "").strip() or None + lead.tags = request.form.get("tags", "").strip() or None + lead.owner_id = int(request.form.get("owner_id")) if request.form.get("owner_id") else current_user.id lead.updated_at = datetime.utcnow() - + if safe_commit(): - flash(_('Lead updated successfully'), 'success') - return redirect(url_for('leads.view_lead', lead_id=lead_id)) + flash(_("Lead updated successfully"), "success") + return redirect(url_for("leads.view_lead", lead_id=lead_id)) except Exception as e: db.session.rollback() - flash(_('Error updating lead: %(error)s', error=str(e)), 'error') - - return render_template('leads/form.html', lead=lead, lead_statuses=LEAD_STATUSES) + flash(_("Error updating lead: %(error)s", error=str(e)), "error") -@leads_bp.route('/leads//convert-to-client', methods=['GET', 'POST']) + return render_template("leads/form.html", lead=lead, lead_statuses=LEAD_STATUSES) + + +@leads_bp.route("/leads//convert-to-client", methods=["GET", "POST"]) @login_required def convert_to_client(lead_id): """Convert a lead to a client""" lead = Lead.query.get_or_404(lead_id) - + if lead.is_converted: - flash(_('Lead has already been converted'), 'error') - return redirect(url_for('leads.view_lead', lead_id=lead_id)) - - if request.method == 'POST': + flash(_("Lead has already been converted"), "error") + return redirect(url_for("leads.view_lead", lead_id=lead_id)) + + if request.method == "POST": try: # Create new client from lead from app.models import Client - + client = Client( name=lead.company_name or f"{lead.first_name} {lead.last_name}", contact_person=f"{lead.first_name} {lead.last_name}", email=lead.email, phone=lead.phone, description=f"Converted from lead: {lead.display_name}", - status='active' + status="active", ) - + db.session.add(client) db.session.flush() # Get client ID - + # Convert lead lead.convert_to_client(client.id, current_user.id) - + # Create primary contact from lead from app.models import Contact + contact = Contact( client_id=client.id, first_name=lead.first_name, @@ -195,122 +206,124 @@ def convert_to_client(lead_id): phone=lead.phone, title=lead.title, is_primary=True, - created_by=current_user.id + created_by=current_user.id, ) db.session.add(contact) - + if safe_commit(): - flash(_('Lead converted to client successfully'), 'success') - return redirect(url_for('clients.view_client', client_id=client.id)) + flash(_("Lead converted to client successfully"), "success") + return redirect(url_for("clients.view_client", client_id=client.id)) except Exception as e: db.session.rollback() - flash(_('Error converting lead: %(error)s', error=str(e)), 'error') - - return render_template('leads/convert_to_client.html', lead=lead) + flash(_("Error converting lead: %(error)s", error=str(e)), "error") + + return render_template("leads/convert_to_client.html", lead=lead) + -@leads_bp.route('/leads//convert-to-deal', methods=['GET', 'POST']) +@leads_bp.route("/leads//convert-to-deal", methods=["GET", "POST"]) @login_required def convert_to_deal(lead_id): """Convert a lead to a deal""" lead = Lead.query.get_or_404(lead_id) - + if lead.is_converted: - flash(_('Lead has already been converted'), 'error') - return redirect(url_for('leads.view_lead', lead_id=lead_id)) - - if request.method == 'POST': + flash(_("Lead has already been converted"), "error") + return redirect(url_for("leads.view_lead", lead_id=lead_id)) + + if request.method == "POST": try: # Create new deal from lead deal = Deal( - name=request.form.get('name', f"Deal: {lead.display_name}").strip(), + name=request.form.get("name", f"Deal: {lead.display_name}").strip(), created_by=current_user.id, lead_id=lead_id, - client_id=int(request.form.get('client_id')) if request.form.get('client_id') else None, - description=request.form.get('description', '').strip() or None, - stage=request.form.get('stage', 'prospecting').strip(), + client_id=int(request.form.get("client_id")) if request.form.get("client_id") else None, + description=request.form.get("description", "").strip() or None, + stage=request.form.get("stage", "prospecting").strip(), value=lead.estimated_value, currency_code=lead.currency_code, - probability=int(request.form.get('probability', 50)), + probability=int(request.form.get("probability", 50)), notes=lead.notes, - owner_id=current_user.id + owner_id=current_user.id, ) - + # Parse expected close date - close_date_str = request.form.get('expected_close_date', '').strip() + close_date_str = request.form.get("expected_close_date", "").strip() if close_date_str: try: - deal.expected_close_date = datetime.strptime(close_date_str, '%Y-%m-%d').date() + deal.expected_close_date = datetime.strptime(close_date_str, "%Y-%m-%d").date() except ValueError: pass - + db.session.add(deal) db.session.flush() # Get deal ID - + # Convert lead lead.convert_to_deal(deal.id, current_user.id) - + if safe_commit(): - flash(_('Lead converted to deal successfully'), 'success') - return redirect(url_for('deals.view_deal', deal_id=deal.id)) + flash(_("Lead converted to deal successfully"), "success") + return redirect(url_for("deals.view_deal", deal_id=deal.id)) except Exception as e: db.session.rollback() - flash(_('Error converting lead: %(error)s', error=str(e)), 'error') - + flash(_("Error converting lead: %(error)s", error=str(e)), "error") + # Get clients for selection - clients = Client.query.filter_by(status='active').order_by(Client.name).all() - - return render_template('leads/convert_to_deal.html', lead=lead, clients=clients) + clients = Client.query.filter_by(status="active").order_by(Client.name).all() + + return render_template("leads/convert_to_deal.html", lead=lead, clients=clients) + -@leads_bp.route('/leads//mark-lost', methods=['POST']) +@leads_bp.route("/leads//mark-lost", methods=["POST"]) @login_required def mark_lost(lead_id): """Mark a lead as lost""" lead = Lead.query.get_or_404(lead_id) - + try: lead.mark_lost() - + if safe_commit(): - flash(_('Lead marked as lost'), 'success') + flash(_("Lead marked as lost"), "success") except Exception as e: db.session.rollback() - flash(_('Error marking lead as lost: %(error)s', error=str(e)), 'error') - - return redirect(url_for('leads.view_lead', lead_id=lead_id)) + flash(_("Error marking lead as lost: %(error)s", error=str(e)), "error") + + return redirect(url_for("leads.view_lead", lead_id=lead_id)) -@leads_bp.route('/leads//activities/create', methods=['GET', 'POST']) + +@leads_bp.route("/leads//activities/create", methods=["GET", "POST"]) @login_required def create_activity(lead_id): """Create an activity for a lead""" lead = Lead.query.get_or_404(lead_id) - - if request.method == 'POST': + + if request.method == "POST": try: - activity_date_str = request.form.get('activity_date', '') + activity_date_str = request.form.get("activity_date", "") activity_date = parse_local_datetime(activity_date_str) if activity_date_str else datetime.utcnow() - - due_date_str = request.form.get('due_date', '') + + due_date_str = request.form.get("due_date", "") due_date = parse_local_datetime(due_date_str) if due_date_str else None - + activity = LeadActivity( lead_id=lead_id, - type=request.form.get('type', 'note').strip(), + type=request.form.get("type", "note").strip(), created_by=current_user.id, - subject=request.form.get('subject', '').strip() or None, - description=request.form.get('description', '').strip() or None, + subject=request.form.get("subject", "").strip() or None, + description=request.form.get("description", "").strip() or None, activity_date=activity_date, due_date=due_date, - status=request.form.get('status', 'completed').strip() or 'completed' + status=request.form.get("status", "completed").strip() or "completed", ) - + db.session.add(activity) - + if safe_commit(): - flash(_('Activity recorded successfully'), 'success') - return redirect(url_for('leads.view_lead', lead_id=lead_id)) + flash(_("Activity recorded successfully"), "success") + return redirect(url_for("leads.view_lead", lead_id=lead_id)) except Exception as e: db.session.rollback() - flash(_('Error recording activity: %(error)s', error=str(e)), 'error') - - return render_template('leads/activity_form.html', lead=lead, activity=None) + flash(_("Error recording activity: %(error)s", error=str(e)), "error") + return render_template("leads/activity_form.html", lead=lead, activity=None) diff --git a/app/routes/main.py b/app/routes/main.py index 1b88bad2..0a186df3 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -12,175 +12,180 @@ import os from app.utils.posthog_segmentation import update_user_segments_if_needed -main_bp = Blueprint('main', __name__) +main_bp = Blueprint("main", __name__) -@main_bp.route('/') -@main_bp.route('/dashboard') + +@main_bp.route("/") +@main_bp.route("/dashboard") @login_required def dashboard(): """Main dashboard showing active timer and recent entries""" # Track dashboard page view track_page_view("dashboard") - + # Update user segments periodically (cached, not every request) update_user_segments_if_needed(current_user.id, current_user) - + # Get user's active timer active_timer = current_user.active_timer - + # Get recent entries for the user recent_entries = current_user.get_recent_entries(limit=10) - + # Get active projects for timer dropdown - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Get user statistics today = datetime.utcnow().date() week_start = today - timedelta(days=today.weekday()) month_start = today.replace(day=1) - - today_hours = TimeEntry.get_total_hours_for_period( - start_date=today, - user_id=current_user.id - ) - - week_hours = TimeEntry.get_total_hours_for_period( - start_date=week_start, - user_id=current_user.id - ) - - month_hours = TimeEntry.get_total_hours_for_period( - start_date=month_start, - user_id=current_user.id - ) - + + today_hours = TimeEntry.get_total_hours_for_period(start_date=today, user_id=current_user.id) + + week_hours = TimeEntry.get_total_hours_for_period(start_date=week_start, user_id=current_user.id) + + month_hours = TimeEntry.get_total_hours_for_period(start_date=month_start, user_id=current_user.id) + # Build Top Projects (last 30 days) based on user's activity period_start = today - timedelta(days=30) entries_30 = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= period_start, - TimeEntry.user_id == current_user.id + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= period_start, TimeEntry.user_id == current_user.id ).all() project_hours = {} for e in entries_30: if not e.project: continue - project_hours.setdefault(e.project.id, { - 'project': e.project, - 'hours': 0.0, - 'billable_hours': 0.0 - }) - project_hours[e.project.id]['hours'] += e.duration_hours + project_hours.setdefault(e.project.id, {"project": e.project, "hours": 0.0, "billable_hours": 0.0}) + project_hours[e.project.id]["hours"] += e.duration_hours if e.billable and e.project.billable: - project_hours[e.project.id]['billable_hours'] += e.duration_hours - top_projects = sorted(project_hours.values(), key=lambda x: x['hours'], reverse=True)[:5] - + project_hours[e.project.id]["billable_hours"] += e.duration_hours + top_projects = sorted(project_hours.values(), key=lambda x: x["hours"], reverse=True)[:5] + # Get current week goal current_week_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) if current_week_goal: current_week_goal.update_status() - + # Get user's time entry templates (most recently used first) from sqlalchemy import desc - templates = TimeEntryTemplate.query.filter_by( - user_id=current_user.id - ).order_by(desc(TimeEntryTemplate.last_used_at)).limit(5).all() - + + templates = ( + TimeEntryTemplate.query.filter_by(user_id=current_user.id) + .order_by(desc(TimeEntryTemplate.last_used_at)) + .limit(5) + .all() + ) + # Get recent activities for activity feed widget - recent_activities = Activity.get_recent( - user_id=None if current_user.is_admin else current_user.id, - limit=10 + recent_activities = Activity.get_recent(user_id=None if current_user.is_admin else current_user.id, limit=10) + + return render_template( + "main/dashboard.html", + active_timer=active_timer, + recent_entries=recent_entries, + active_projects=active_projects, + today_hours=today_hours, + week_hours=week_hours, + month_hours=month_hours, + top_projects=top_projects, + current_week_goal=current_week_goal, + templates=templates, + recent_activities=recent_activities, ) - return render_template('main/dashboard.html', - active_timer=active_timer, - recent_entries=recent_entries, - active_projects=active_projects, - today_hours=today_hours, - week_hours=week_hours, - month_hours=month_hours, - top_projects=top_projects, - current_week_goal=current_week_goal, - templates=templates, - recent_activities=recent_activities) - -@main_bp.route('/_health') + +@main_bp.route("/_health") def health_check(): """Liveness probe: shallow checks only, no DB access""" - return {'status': 'healthy'}, 200 + return {"status": "healthy"}, 200 + -@main_bp.route('/_ready') +@main_bp.route("/_ready") def readiness_check(): """Readiness probe: verify DB connectivity and critical dependencies""" try: - db.session.execute(text('SELECT 1')) - return {'status': 'ready', 'timestamp': local_now().isoformat()}, 200 + db.session.execute(text("SELECT 1")) + return {"status": "ready", "timestamp": local_now().isoformat()}, 200 except Exception as e: - return {'status': 'not_ready', 'error': 'db_unreachable'}, 503 + return {"status": "not_ready", "error": "db_unreachable"}, 503 -@main_bp.route('/about') + +@main_bp.route("/about") def about(): """About page""" - return render_template('main/about.html') + return render_template("main/about.html") + -@main_bp.route('/help') +@main_bp.route("/help") def help(): """Help page""" - return render_template('main/help.html') + return render_template("main/help.html") + -@main_bp.route('/debug/i18n') +@main_bp.route("/debug/i18n") @login_required def debug_i18n(): """Debug endpoint to check i18n status (admin only)""" from flask_login import current_user + if not current_user.is_admin: - return jsonify({'error': 'Admin only'}), 403 - + return jsonify({"error": "Admin only"}), 403 + from flask_babel import get_locale import os - + locale = str(get_locale()) - session_lang = session.get('preferred_language') - user_lang = getattr(current_user, 'preferred_language', None) - + session_lang = session.get("preferred_language") + user_lang = getattr(current_user, "preferred_language", None) + # Check if .mo file exists for current locale - base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')) - translations_dir = os.path.join(base_path, 'translations') - mo_path = os.path.join(translations_dir, locale, 'LC_MESSAGES', 'messages.mo') - po_path = os.path.join(translations_dir, locale, 'LC_MESSAGES', 'messages.po') - - return jsonify({ - 'current_locale': locale, - 'session_language': session_lang, - 'user_language': user_lang, - 'mo_file_exists': os.path.exists(mo_path), - 'po_file_exists': os.path.exists(po_path), - 'mo_path': mo_path, - 'nb_mo_exists': os.path.exists(os.path.join(translations_dir, 'nb', 'LC_MESSAGES', 'messages.mo')), - 'no_mo_exists': os.path.exists(os.path.join(translations_dir, 'no', 'LC_MESSAGES', 'messages.mo')), - }) - -@main_bp.route('/i18n/set-language', methods=['POST', 'GET']) + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + translations_dir = os.path.join(base_path, "translations") + mo_path = os.path.join(translations_dir, locale, "LC_MESSAGES", "messages.mo") + po_path = os.path.join(translations_dir, locale, "LC_MESSAGES", "messages.po") + + return jsonify( + { + "current_locale": locale, + "session_language": session_lang, + "user_language": user_lang, + "mo_file_exists": os.path.exists(mo_path), + "po_file_exists": os.path.exists(po_path), + "mo_path": mo_path, + "nb_mo_exists": os.path.exists(os.path.join(translations_dir, "nb", "LC_MESSAGES", "messages.mo")), + "no_mo_exists": os.path.exists(os.path.join(translations_dir, "no", "LC_MESSAGES", "messages.mo")), + } + ) + + +@main_bp.route("/i18n/set-language", methods=["POST", "GET"]) def set_language(): """Set preferred UI language via session or user profile.""" - lang = request.args.get('lang') or (request.form.get('lang') if request.method == 'POST' else None) or (request.json.get('lang') if request.is_json else None) or 'en' + lang = ( + request.args.get("lang") + or (request.form.get("lang") if request.method == "POST" else None) + or (request.json.get("lang") if request.is_json else None) + or "en" + ) lang = lang.strip().lower() from flask import current_app - supported = list(current_app.config.get('LANGUAGES', {}).keys()) or ['en'] + + supported = list(current_app.config.get("LANGUAGES", {}).keys()) or ["en"] if lang not in supported: - lang = current_app.config.get('BABEL_DEFAULT_LOCALE', 'en') - + lang = current_app.config.get("BABEL_DEFAULT_LOCALE", "en") + # Make session permanent to ensure it persists across requests session.permanent = True - + # Persist in session for guests - session['preferred_language'] = lang + session["preferred_language"] = lang session.modified = True # Force session save - + # If authenticated, persist to user profile try: from flask_login import current_user - if current_user and getattr(current_user, 'is_authenticated', False): + + if current_user and getattr(current_user, "is_authenticated", False): # Update user preference in database current_user.preferred_language = lang # Add to session and commit @@ -194,74 +199,73 @@ def set_language(): db.session.rollback() except Exception: pass - + # Redirect back if referer exists, add timestamp to force reload - next_url = request.headers.get('Referer') or url_for('main.dashboard') + next_url = request.headers.get("Referer") or url_for("main.dashboard") # Add cache-busting parameter to ensure fresh page load import time - separator = '&' if '?' in next_url else '?' + + separator = "&" if "?" in next_url else "?" next_url = f"{next_url}{separator}_lang_refresh={int(time.time())}" response = make_response(redirect(next_url)) # Ensure no caching - response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate' - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = '0' + response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" return response -@main_bp.route('/search') + +@main_bp.route("/search") @login_required def search(): """Search time entries""" - query = request.args.get('q', '').strip() - page = request.args.get('page', 1, type=int) - + query = request.args.get("q", "").strip() + page = request.args.get("page", 1, type=int) + if not query: - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Search in time entries from sqlalchemy import or_ - entries = TimeEntry.query.filter( - TimeEntry.user_id == current_user.id, - TimeEntry.end_time.isnot(None), - or_( - TimeEntry.notes.ilike(f'%{query}%'), - TimeEntry.tags.ilike(f'%{query}%') + + entries = ( + TimeEntry.query.filter( + TimeEntry.user_id == current_user.id, + TimeEntry.end_time.isnot(None), + or_(TimeEntry.notes.ilike(f"%{query}%"), TimeEntry.tags.ilike(f"%{query}%")), ) - ).order_by(TimeEntry.start_time.desc()).paginate( - page=page, - per_page=20, - error_out=False + .order_by(TimeEntry.start_time.desc()) + .paginate(page=page, per_page=20, error_out=False) ) - - return render_template('main/search.html', entries=entries, query=query) + + return render_template("main/search.html", entries=entries, query=query) -@main_bp.route('/service-worker.js') +@main_bp.route("/service-worker.js") def service_worker(): """Serve a minimal service worker for PWA offline caching.""" # Build absolute URLs for static assets to ensure proper caching assets = [ - '/', + "/", # CSS - url_for('static', filename='dist/output.css'), - url_for('static', filename='enhanced-ui.css'), - url_for('static', filename='ui-enhancements.css'), - url_for('static', filename='form-validation.css'), - url_for('static', filename='keyboard-shortcuts.css'), - url_for('static', filename='toast-notifications.css'), + url_for("static", filename="dist/output.css"), + url_for("static", filename="enhanced-ui.css"), + url_for("static", filename="ui-enhancements.css"), + url_for("static", filename="form-validation.css"), + url_for("static", filename="keyboard-shortcuts.css"), + url_for("static", filename="toast-notifications.css"), # JS - url_for('static', filename='mobile.js'), - url_for('static', filename='commands.js'), - url_for('static', filename='enhanced-ui.js'), - url_for('static', filename='ui-enhancements.js'), - url_for('static', filename='toast-notifications.js'), + url_for("static", filename="mobile.js"), + url_for("static", filename="commands.js"), + url_for("static", filename="enhanced-ui.js"), + url_for("static", filename="ui-enhancements.js"), + url_for("static", filename="toast-notifications.js"), ] preamble = "const CACHE_NAME='tt-cache-v2';\n" assets_js = "const ASSETS=" + json.dumps(assets) + ";\n\n" - body = ( - "self.addEventListener('install', (event)=>{ event.waitUntil(caches.open(CACHE_NAME).then((c)=>c.addAll(ASSETS))); self.skipWaiting()); });\n" - .replace('); );', ');') # guard against formatting - ) + body = "self.addEventListener('install', (event)=>{ event.waitUntil(caches.open(CACHE_NAME).then((c)=>c.addAll(ASSETS))); self.skipWaiting()); });\n".replace( + "); );", ");" + ) # guard against formatting body = ( "self.addEventListener('install', (event)=>{\n" " event.waitUntil((async()=>{\n" @@ -303,5 +307,5 @@ def service_worker(): ) sw_js = preamble + assets_js + body resp = make_response(sw_js) - resp.headers['Content-Type'] = 'application/javascript' + resp.headers["Content-Type"] = "application/javascript" return resp diff --git a/app/routes/mileage.py b/app/routes/mileage.py index 0866ecd8..b8bd6dc3 100644 --- a/app/routes/mileage.py +++ b/app/routes/mileage.py @@ -9,63 +9,59 @@ import csv import io -mileage_bp = Blueprint('mileage', __name__) +mileage_bp = Blueprint("mileage", __name__) -@mileage_bp.route('/mileage') +@mileage_bp.route("/mileage") @login_required def list_mileage(): """List all mileage entries with filters""" from app import track_page_view + track_page_view("mileage_list") - - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 25, type=int) - + + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 25, type=int) + # Filter parameters - status = request.args.get('status', '').strip() - project_id = request.args.get('project_id', type=int) - client_id = request.args.get('client_id', type=int) - start_date = request.args.get('start_date', '').strip() - end_date = request.args.get('end_date', '').strip() - search = request.args.get('search', '').strip() - + status = request.args.get("status", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + search = request.args.get("search", "").strip() + # Build query query = Mileage.query - + # Non-admin users can only see their own mileage or mileage they approved if not current_user.is_admin: - query = query.filter( - db.or_( - Mileage.user_id == current_user.id, - Mileage.approved_by == current_user.id - ) - ) - + query = query.filter(db.or_(Mileage.user_id == current_user.id, Mileage.approved_by == current_user.id)) + # Apply filters if status: query = query.filter(Mileage.status == status) - + if project_id: query = query.filter(Mileage.project_id == project_id) - + if client_id: query = query.filter(Mileage.client_id == client_id) - + if start_date: try: - start = datetime.strptime(start_date, '%Y-%m-%d').date() + start = datetime.strptime(start_date, "%Y-%m-%d").date() query = query.filter(Mileage.trip_date >= start) except ValueError: pass - + if end_date: try: - end = datetime.strptime(end_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() query = query.filter(Mileage.trip_date <= end) except ValueError: pass - + if search: like = f"%{search}%" query = query.filter( @@ -73,57 +69,50 @@ def list_mileage(): Mileage.purpose.ilike(like), Mileage.description.ilike(like), Mileage.start_location.ilike(like), - Mileage.end_location.ilike(like) + Mileage.end_location.ilike(like), ) ) - + # Paginate mileage_pagination = query.order_by(Mileage.trip_date.desc()).paginate( - page=page, - per_page=per_page, - error_out=False + page=page, per_page=per_page, error_out=False ) - + # Get filter options - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() - + # Calculate totals start_date_obj = None end_date_obj = None - + if start_date: try: - start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() + start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date() except ValueError: pass - + if end_date: try: - end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() + end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").date() except ValueError: pass - + total_distance = Mileage.get_total_distance( - user_id=None if current_user.is_admin else current_user.id, - start_date=start_date_obj, - end_date=end_date_obj + user_id=None if current_user.is_admin else current_user.id, start_date=start_date_obj, end_date=end_date_obj ) - + total_amount_query = db.session.query( - db.func.sum(Mileage.calculated_amount * db.case( - (Mileage.is_round_trip, 2), - else_=1 - )) - ).filter(Mileage.status.in_(['approved', 'reimbursed'])) - + db.func.sum(Mileage.calculated_amount * db.case((Mileage.is_round_trip, 2), else_=1)) + ).filter(Mileage.status.in_(["approved", "reimbursed"])) + if not current_user.is_admin: total_amount_query = total_amount_query.filter(Mileage.user_id == current_user.id) - + total_amount = total_amount_query.scalar() or 0 - + return render_template( - 'mileage/list.html', + "mileage/list.html", mileage_entries=mileage_pagination.items, pagination=mileage_pagination, projects=projects, @@ -136,49 +125,45 @@ def list_mileage(): client_id=client_id, start_date=start_date, end_date=end_date, - search=search + search=search, ) -@mileage_bp.route('/mileage/create', methods=['GET', 'POST']) +@mileage_bp.route("/mileage/create", methods=["GET", "POST"]) @login_required def create_mileage(): """Create a new mileage entry""" - if request.method == 'GET': - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + if request.method == "GET": + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() default_rates = Mileage.get_default_rates() - + return render_template( - 'mileage/form.html', - mileage=None, - projects=projects, - clients=clients, - default_rates=default_rates + "mileage/form.html", mileage=None, projects=projects, clients=clients, default_rates=default_rates ) - + try: # Get form data - trip_date = request.form.get('trip_date', '').strip() - purpose = request.form.get('purpose', '').strip() - description = request.form.get('description', '').strip() - start_location = request.form.get('start_location', '').strip() - end_location = request.form.get('end_location', '').strip() - distance_km = request.form.get('distance_km', '').strip() - rate_per_km = request.form.get('rate_per_km', '').strip() - + trip_date = request.form.get("trip_date", "").strip() + purpose = request.form.get("purpose", "").strip() + description = request.form.get("description", "").strip() + start_location = request.form.get("start_location", "").strip() + end_location = request.form.get("end_location", "").strip() + distance_km = request.form.get("distance_km", "").strip() + rate_per_km = request.form.get("rate_per_km", "").strip() + # Validate required fields if not all([trip_date, purpose, start_location, end_location, distance_km, rate_per_km]): - flash(_('Please fill in all required fields'), 'error') - return redirect(url_for('mileage.create_mileage')) - + flash(_("Please fill in all required fields"), "error") + return redirect(url_for("mileage.create_mileage")) + # Parse date try: - trip_date_obj = datetime.strptime(trip_date, '%Y-%m-%d').date() + trip_date_obj = datetime.strptime(trip_date, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('mileage.create_mileage')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("mileage.create_mileage")) + # Create mileage entry mileage = Mileage( user_id=current_user.id, @@ -189,397 +174,422 @@ def create_mileage(): distance_km=Decimal(distance_km), rate_per_km=Decimal(rate_per_km), description=description, - project_id=request.form.get('project_id', type=int), - client_id=request.form.get('client_id', type=int), - start_odometer=request.form.get('start_odometer'), - end_odometer=request.form.get('end_odometer'), - vehicle_type=request.form.get('vehicle_type'), - vehicle_description=request.form.get('vehicle_description'), - license_plate=request.form.get('license_plate'), - is_round_trip=request.form.get('is_round_trip') == 'on', - currency_code=request.form.get('currency_code', 'EUR'), - notes=request.form.get('notes') + project_id=request.form.get("project_id", type=int), + client_id=request.form.get("client_id", type=int), + start_odometer=request.form.get("start_odometer"), + end_odometer=request.form.get("end_odometer"), + vehicle_type=request.form.get("vehicle_type"), + vehicle_description=request.form.get("vehicle_description"), + license_plate=request.form.get("license_plate"), + is_round_trip=request.form.get("is_round_trip") == "on", + currency_code=request.form.get("currency_code", "EUR"), + notes=request.form.get("notes"), ) - + db.session.add(mileage) - + # Create expense if requested - if request.form.get('create_expense') == 'on': + if request.form.get("create_expense") == "on": expense = mileage.create_expense() if expense: db.session.add(expense) - + if safe_commit(db): - flash(_('Mileage entry created successfully'), 'success') - log_event('mileage_created', user_id=current_user.id, mileage_id=mileage.id) - track_event(current_user.id, 'mileage.created', { - 'mileage_id': mileage.id, - 'distance_km': float(distance_km), - 'amount': float(mileage.total_amount) - }) - return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id)) + flash(_("Mileage entry created successfully"), "success") + log_event("mileage_created", user_id=current_user.id, mileage_id=mileage.id) + track_event( + current_user.id, + "mileage.created", + {"mileage_id": mileage.id, "distance_km": float(distance_km), "amount": float(mileage.total_amount)}, + ) + return redirect(url_for("mileage.view_mileage", mileage_id=mileage.id)) else: - flash(_('Error creating mileage entry'), 'error') - return redirect(url_for('mileage.create_mileage')) - + flash(_("Error creating mileage entry"), "error") + return redirect(url_for("mileage.create_mileage")) + except Exception as e: current_app.logger.error(f"Error creating mileage entry: {e}") - flash(_('Error creating mileage entry'), 'error') - return redirect(url_for('mileage.create_mileage')) + flash(_("Error creating mileage entry"), "error") + return redirect(url_for("mileage.create_mileage")) -@mileage_bp.route('/mileage/') +@mileage_bp.route("/mileage/") @login_required def view_mileage(mileage_id): """View mileage entry details""" mileage = Mileage.query.get_or_404(mileage_id) - + # Check permission if not current_user.is_admin and mileage.user_id != current_user.id and mileage.approved_by != current_user.id: - flash(_('You do not have permission to view this mileage entry'), 'error') - return redirect(url_for('mileage.list_mileage')) - + flash(_("You do not have permission to view this mileage entry"), "error") + return redirect(url_for("mileage.list_mileage")) + from app import track_page_view - track_page_view("mileage_detail", properties={'mileage_id': mileage_id}) - - return render_template('mileage/view.html', mileage=mileage) + track_page_view("mileage_detail", properties={"mileage_id": mileage_id}) + + return render_template("mileage/view.html", mileage=mileage) -@mileage_bp.route('/mileage//edit', methods=['GET', 'POST']) + +@mileage_bp.route("/mileage//edit", methods=["GET", "POST"]) @login_required def edit_mileage(mileage_id): """Edit a mileage entry""" mileage = Mileage.query.get_or_404(mileage_id) - + # Check permission if not current_user.is_admin and mileage.user_id != current_user.id: - flash(_('You do not have permission to edit this mileage entry'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + flash(_("You do not have permission to edit this mileage entry"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + # Cannot edit approved or reimbursed entries without admin privileges - if not current_user.is_admin and mileage.status in ['approved', 'reimbursed']: - flash(_('Cannot edit approved or reimbursed mileage entries'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - - if request.method == 'GET': - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + if not current_user.is_admin and mileage.status in ["approved", "reimbursed"]: + flash(_("Cannot edit approved or reimbursed mileage entries"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + + if request.method == "GET": + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() default_rates = Mileage.get_default_rates() - + return render_template( - 'mileage/form.html', - mileage=mileage, - projects=projects, - clients=clients, - default_rates=default_rates + "mileage/form.html", mileage=mileage, projects=projects, clients=clients, default_rates=default_rates ) - + try: # Update fields - trip_date = request.form.get('trip_date', '').strip() - mileage.trip_date = datetime.strptime(trip_date, '%Y-%m-%d').date() - mileage.purpose = request.form.get('purpose', '').strip() - mileage.description = request.form.get('description', '').strip() - mileage.start_location = request.form.get('start_location', '').strip() - mileage.end_location = request.form.get('end_location', '').strip() - mileage.distance_km = Decimal(request.form.get('distance_km', '0')) - mileage.rate_per_km = Decimal(request.form.get('rate_per_km', '0')) + trip_date = request.form.get("trip_date", "").strip() + mileage.trip_date = datetime.strptime(trip_date, "%Y-%m-%d").date() + mileage.purpose = request.form.get("purpose", "").strip() + mileage.description = request.form.get("description", "").strip() + mileage.start_location = request.form.get("start_location", "").strip() + mileage.end_location = request.form.get("end_location", "").strip() + mileage.distance_km = Decimal(request.form.get("distance_km", "0")) + mileage.rate_per_km = Decimal(request.form.get("rate_per_km", "0")) mileage.calculated_amount = mileage.distance_km * mileage.rate_per_km - mileage.project_id = request.form.get('project_id', type=int) - mileage.client_id = request.form.get('client_id', type=int) - mileage.vehicle_type = request.form.get('vehicle_type') - mileage.vehicle_description = request.form.get('vehicle_description') - mileage.license_plate = request.form.get('license_plate') - mileage.is_round_trip = request.form.get('is_round_trip') == 'on' - mileage.currency_code = request.form.get('currency_code', 'EUR') - mileage.notes = request.form.get('notes') + mileage.project_id = request.form.get("project_id", type=int) + mileage.client_id = request.form.get("client_id", type=int) + mileage.vehicle_type = request.form.get("vehicle_type") + mileage.vehicle_description = request.form.get("vehicle_description") + mileage.license_plate = request.form.get("license_plate") + mileage.is_round_trip = request.form.get("is_round_trip") == "on" + mileage.currency_code = request.form.get("currency_code", "EUR") + mileage.notes = request.form.get("notes") mileage.updated_at = datetime.utcnow() - + if safe_commit(db): - flash(_('Mileage entry updated successfully'), 'success') - log_event('mileage_updated', user_id=current_user.id, mileage_id=mileage.id) - track_event(current_user.id, 'mileage.updated', {'mileage_id': mileage.id}) - return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id)) + flash(_("Mileage entry updated successfully"), "success") + log_event("mileage_updated", user_id=current_user.id, mileage_id=mileage.id) + track_event(current_user.id, "mileage.updated", {"mileage_id": mileage.id}) + return redirect(url_for("mileage.view_mileage", mileage_id=mileage.id)) else: - flash(_('Error updating mileage entry'), 'error') - return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id)) - + flash(_("Error updating mileage entry"), "error") + return redirect(url_for("mileage.edit_mileage", mileage_id=mileage_id)) + except Exception as e: current_app.logger.error(f"Error updating mileage entry: {e}") - flash(_('Error updating mileage entry'), 'error') - return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id)) + flash(_("Error updating mileage entry"), "error") + return redirect(url_for("mileage.edit_mileage", mileage_id=mileage_id)) -@mileage_bp.route('/mileage//delete', methods=['POST']) +@mileage_bp.route("/mileage//delete", methods=["POST"]) @login_required def delete_mileage(mileage_id): """Delete a mileage entry""" mileage = Mileage.query.get_or_404(mileage_id) - + # Check permission if not current_user.is_admin and mileage.user_id != current_user.id: - flash(_('You do not have permission to delete this mileage entry'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + flash(_("You do not have permission to delete this mileage entry"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + try: db.session.delete(mileage) - + if safe_commit(db): - flash(_('Mileage entry deleted successfully'), 'success') - log_event('mileage_deleted', user_id=current_user.id, mileage_id=mileage_id) - track_event(current_user.id, 'mileage.deleted', {'mileage_id': mileage_id}) + flash(_("Mileage entry deleted successfully"), "success") + log_event("mileage_deleted", user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, "mileage.deleted", {"mileage_id": mileage_id}) else: - flash(_('Error deleting mileage entry'), 'error') - + flash(_("Error deleting mileage entry"), "error") + except Exception as e: current_app.logger.error(f"Error deleting mileage entry: {e}") - flash(_('Error deleting mileage entry'), 'error') - - return redirect(url_for('mileage.list_mileage')) + flash(_("Error deleting mileage entry"), "error") + + return redirect(url_for("mileage.list_mileage")) -@mileage_bp.route('/mileage/bulk-delete', methods=['POST']) + +@mileage_bp.route("/mileage/bulk-delete", methods=["POST"]) @login_required def bulk_delete_mileage(): """Delete multiple mileage entries at once""" - mileage_ids = request.form.getlist('mileage_ids[]') - + mileage_ids = request.form.getlist("mileage_ids[]") + if not mileage_ids: - flash(_('No mileage entries selected for deletion'), 'warning') - return redirect(url_for('mileage.list_mileage')) - + flash(_("No mileage entries selected for deletion"), "warning") + return redirect(url_for("mileage.list_mileage")) + deleted_count = 0 skipped_count = 0 errors = [] - + for mileage_id_str in mileage_ids: try: mileage_id = int(mileage_id_str) mileage = Mileage.query.get(mileage_id) - + if not mileage: continue - + # Check permissions if not current_user.is_admin and mileage.user_id != current_user.id: skipped_count += 1 errors.append(f"Mileage #{mileage_id_str}: No permission") continue - + db.session.delete(mileage) deleted_count += 1 - + except Exception as e: skipped_count += 1 errors.append(f"ID {mileage_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: if not safe_commit(db): - flash(_('Could not delete mileage entries due to a database error. Please check server logs.'), 'error') - return redirect(url_for('mileage.list_mileage')) - - log_event('mileage_bulk_deleted', user_id=current_user.id, count=deleted_count) - track_event(current_user.id, 'mileage.bulk_deleted', {'count': deleted_count}) - + flash(_("Could not delete mileage entries due to a database error. Please check server logs."), "error") + return redirect(url_for("mileage.list_mileage")) + + log_event("mileage_bulk_deleted", user_id=current_user.id, count=deleted_count) + track_event(current_user.id, "mileage.bulk_deleted", {"count": deleted_count}) + # Show appropriate messages if deleted_count > 0: - flash(_('Successfully deleted %(count)d mileage entr%(plural)s', count=deleted_count, plural='y' if deleted_count == 1 else 'ies'), 'success') - + flash( + _( + "Successfully deleted %(count)d mileage entr%(plural)s", + count=deleted_count, + plural="y" if deleted_count == 1 else "ies", + ), + "success", + ) + if skipped_count > 0: - flash(_('Skipped %(count)d mileage entr%(plural)s: %(errors)s', count=skipped_count, plural='y' if skipped_count == 1 else 'ies', errors="; ".join(errors[:3])), 'warning') - - return redirect(url_for('mileage.list_mileage')) + flash( + _( + "Skipped %(count)d mileage entr%(plural)s: %(errors)s", + count=skipped_count, + plural="y" if skipped_count == 1 else "ies", + errors="; ".join(errors[:3]), + ), + "warning", + ) -@mileage_bp.route('/mileage/bulk-status', methods=['POST']) + return redirect(url_for("mileage.list_mileage")) + + +@mileage_bp.route("/mileage/bulk-status", methods=["POST"]) @login_required def bulk_update_status(): """Update status for multiple mileage entries at once""" - mileage_ids = request.form.getlist('mileage_ids[]') - new_status = request.form.get('status', '').strip() - + mileage_ids = request.form.getlist("mileage_ids[]") + new_status = request.form.get("status", "").strip() + if not mileage_ids: - flash(_('No mileage entries selected'), 'warning') - return redirect(url_for('mileage.list_mileage')) - + flash(_("No mileage entries selected"), "warning") + return redirect(url_for("mileage.list_mileage")) + # Validate status - valid_statuses = ['pending', 'approved', 'rejected', 'reimbursed'] + valid_statuses = ["pending", "approved", "rejected", "reimbursed"] if not new_status or new_status not in valid_statuses: - flash(_('Invalid status value'), 'error') - return redirect(url_for('mileage.list_mileage')) - + flash(_("Invalid status value"), "error") + return redirect(url_for("mileage.list_mileage")) + updated_count = 0 skipped_count = 0 - + for mileage_id_str in mileage_ids: try: mileage_id = int(mileage_id_str) mileage = Mileage.query.get(mileage_id) - + if not mileage: continue - + # Check permissions - non-admin users can only update their own entries if not current_user.is_admin and mileage.user_id != current_user.id: skipped_count += 1 continue - + mileage.status = new_status updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: if not safe_commit(db): - flash(_('Could not update mileage entries due to a database error'), 'error') - return redirect(url_for('mileage.list_mileage')) - - flash(_('Successfully updated %(count)d mileage entr%(plural)s to %(status)s', count=updated_count, plural='y' if updated_count == 1 else 'ies', status=new_status), 'success') - + flash(_("Could not update mileage entries due to a database error"), "error") + return redirect(url_for("mileage.list_mileage")) + + flash( + _( + "Successfully updated %(count)d mileage entr%(plural)s to %(status)s", + count=updated_count, + plural="y" if updated_count == 1 else "ies", + status=new_status, + ), + "success", + ) + if skipped_count > 0: - flash(_('Skipped %(count)d mileage entr%(plural)s (no permission)', count=skipped_count, plural='y' if skipped_count == 1 else 'ies'), 'warning') - - return redirect(url_for('mileage.list_mileage')) + flash( + _( + "Skipped %(count)d mileage entr%(plural)s (no permission)", + count=skipped_count, + plural="y" if skipped_count == 1 else "ies", + ), + "warning", + ) + + return redirect(url_for("mileage.list_mileage")) -@mileage_bp.route('/mileage//approve', methods=['POST']) +@mileage_bp.route("/mileage//approve", methods=["POST"]) @login_required def approve_mileage(mileage_id): """Approve a mileage entry""" if not current_user.is_admin: - flash(_('Only administrators can approve mileage entries'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + flash(_("Only administrators can approve mileage entries"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + mileage = Mileage.query.get_or_404(mileage_id) - - if mileage.status != 'pending': - flash(_('Only pending mileage entries can be approved'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + + if mileage.status != "pending": + flash(_("Only pending mileage entries can be approved"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + try: - notes = request.form.get('approval_notes', '').strip() + notes = request.form.get("approval_notes", "").strip() mileage.approve(current_user.id, notes) - + if safe_commit(db): - flash(_('Mileage entry approved successfully'), 'success') - log_event('mileage_approved', user_id=current_user.id, mileage_id=mileage_id) - track_event(current_user.id, 'mileage.approved', {'mileage_id': mileage_id}) + flash(_("Mileage entry approved successfully"), "success") + log_event("mileage_approved", user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, "mileage.approved", {"mileage_id": mileage_id}) else: - flash(_('Error approving mileage entry'), 'error') - + flash(_("Error approving mileage entry"), "error") + except Exception as e: current_app.logger.error(f"Error approving mileage entry: {e}") - flash(_('Error approving mileage entry'), 'error') - - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + flash(_("Error approving mileage entry"), "error") + + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) -@mileage_bp.route('/mileage//reject', methods=['POST']) +@mileage_bp.route("/mileage//reject", methods=["POST"]) @login_required def reject_mileage(mileage_id): """Reject a mileage entry""" if not current_user.is_admin: - flash(_('Only administrators can reject mileage entries'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + flash(_("Only administrators can reject mileage entries"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + mileage = Mileage.query.get_or_404(mileage_id) - - if mileage.status != 'pending': - flash(_('Only pending mileage entries can be rejected'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + + if mileage.status != "pending": + flash(_("Only pending mileage entries can be rejected"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + try: - reason = request.form.get('rejection_reason', '').strip() + reason = request.form.get("rejection_reason", "").strip() if not reason: - flash(_('Rejection reason is required'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + flash(_("Rejection reason is required"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + mileage.reject(current_user.id, reason) - + if safe_commit(db): - flash(_('Mileage entry rejected'), 'success') - log_event('mileage_rejected', user_id=current_user.id, mileage_id=mileage_id) - track_event(current_user.id, 'mileage.rejected', {'mileage_id': mileage_id}) + flash(_("Mileage entry rejected"), "success") + log_event("mileage_rejected", user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, "mileage.rejected", {"mileage_id": mileage_id}) else: - flash(_('Error rejecting mileage entry'), 'error') - + flash(_("Error rejecting mileage entry"), "error") + except Exception as e: current_app.logger.error(f"Error rejecting mileage entry: {e}") - flash(_('Error rejecting mileage entry'), 'error') - - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + flash(_("Error rejecting mileage entry"), "error") + + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) -@mileage_bp.route('/mileage//reimburse', methods=['POST']) +@mileage_bp.route("/mileage//reimburse", methods=["POST"]) @login_required def mark_reimbursed(mileage_id): """Mark a mileage entry as reimbursed""" if not current_user.is_admin: - flash(_('Only administrators can mark mileage entries as reimbursed'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + flash(_("Only administrators can mark mileage entries as reimbursed"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + mileage = Mileage.query.get_or_404(mileage_id) - - if mileage.status != 'approved': - flash(_('Only approved mileage entries can be marked as reimbursed'), 'error') - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) - + + if mileage.status != "approved": + flash(_("Only approved mileage entries can be marked as reimbursed"), "error") + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) + try: mileage.mark_as_reimbursed() - + if safe_commit(db): - flash(_('Mileage entry marked as reimbursed'), 'success') - log_event('mileage_reimbursed', user_id=current_user.id, mileage_id=mileage_id) - track_event(current_user.id, 'mileage.reimbursed', {'mileage_id': mileage_id}) + flash(_("Mileage entry marked as reimbursed"), "success") + log_event("mileage_reimbursed", user_id=current_user.id, mileage_id=mileage_id) + track_event(current_user.id, "mileage.reimbursed", {"mileage_id": mileage_id}) else: - flash(_('Error marking mileage entry as reimbursed'), 'error') - + flash(_("Error marking mileage entry as reimbursed"), "error") + except Exception as e: current_app.logger.error(f"Error marking mileage entry as reimbursed: {e}") - flash(_('Error marking mileage entry as reimbursed'), 'error') - - return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id)) + flash(_("Error marking mileage entry as reimbursed"), "error") + + return redirect(url_for("mileage.view_mileage", mileage_id=mileage_id)) # API endpoints -@mileage_bp.route('/api/mileage', methods=['GET']) +@mileage_bp.route("/api/mileage", methods=["GET"]) @login_required def api_list_mileage(): """API endpoint to list mileage entries""" - status = request.args.get('status', '').strip() - + status = request.args.get("status", "").strip() + query = Mileage.query - + if not current_user.is_admin: query = query.filter_by(user_id=current_user.id) - + if status: query = query.filter(Mileage.status == status) - + entries = query.order_by(Mileage.trip_date.desc()).all() - - return jsonify({ - 'mileage': [entry.to_dict() for entry in entries], - 'count': len(entries) - }) + + return jsonify({"mileage": [entry.to_dict() for entry in entries], "count": len(entries)}) -@mileage_bp.route('/api/mileage/', methods=['GET']) +@mileage_bp.route("/api/mileage/", methods=["GET"]) @login_required def api_get_mileage(mileage_id): """API endpoint to get a single mileage entry""" mileage = Mileage.query.get_or_404(mileage_id) - + # Check permission if not current_user.is_admin and mileage.user_id != current_user.id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + return jsonify(mileage.to_dict()) -@mileage_bp.route('/api/mileage/default-rates', methods=['GET']) +@mileage_bp.route("/api/mileage/default-rates", methods=["GET"]) @login_required def api_get_default_rates(): """API endpoint to get default mileage rates""" return jsonify(Mileage.get_default_rates()) - diff --git a/app/routes/offers.py b/app/routes/offers.py index 9f01dd36..92a053a6 100644 --- a/app/routes/offers.py +++ b/app/routes/offers.py @@ -8,118 +8,116 @@ from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required, permission_required -quotes_bp = Blueprint('quotes', __name__) +quotes_bp = Blueprint("quotes", __name__) -@quotes_bp.route('/quotes') + +@quotes_bp.route("/quotes") @login_required def list_quotes(): """List all quotes""" - status = request.args.get('status', 'all') - search = request.args.get('search', '').strip() - + status = request.args.get("status", "all") + search = request.args.get("search", "").strip() + query = Quote.query - - if status != 'all': + + if status != "all": query = query.filter_by(status=status) - + if search: like = f"%{search}%" query = query.filter( - db.or_( - Quote.title.ilike(like), - Quote.quote_number.ilike(like), - Quote.description.ilike(like) - ) + db.or_(Quote.title.ilike(like), Quote.quote_number.ilike(like), Quote.description.ilike(like)) ) - + quotes = query.order_by(Quote.created_at.desc()).all() - - return render_template('quotes/list.html', quotes=quotes, status=status, search=search) -@quotes_bp.route('/quotes/create', methods=['GET', 'POST']) + return render_template("quotes/list.html", quotes=quotes, status=status, search=search) + + +@quotes_bp.route("/quotes/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_quotes') +@admin_or_permission_required("create_quotes") def create_quote(): """Create a new quote""" - if request.method == 'POST': - client_id = request.form.get('client_id', '').strip() - title = request.form.get('title', '').strip() - description = request.form.get('description', '').strip() - total_amount = request.form.get('total_amount', '').strip() - hourly_rate = request.form.get('hourly_rate', '').strip() - estimated_hours = request.form.get('estimated_hours', '').strip() - tax_rate = request.form.get('tax_rate', '0').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - valid_until = request.form.get('valid_until', '').strip() - notes = request.form.get('notes', '').strip() - terms = request.form.get('terms', '').strip() - + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + total_amount = request.form.get("total_amount", "").strip() + hourly_rate = request.form.get("hourly_rate", "").strip() + estimated_hours = request.form.get("estimated_hours", "").strip() + tax_rate = request.form.get("tax_rate", "0").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + valid_until = request.form.get("valid_until", "").strip() + notes = request.form.get("notes", "").strip() + terms = request.form.get("terms", "").strip() + try: current_app.logger.info( "POST /quotes/create user=%s title=%s client_id=%s", current_user.username, - title or '', - client_id or '' + title or "", + client_id or "", ) except Exception: pass - + # Validate required fields if not title or not client_id: - flash(_('Quote title and client are required'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Quote title and client are required"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Get client and validate client = Client.query.get(client_id) if not client: - flash(_('Selected client not found'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Selected client not found"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Validate amounts try: total_amount = Decimal(total_amount) if total_amount else None if total_amount is not None and total_amount < 0: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid total amount format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid total amount format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + try: hourly_rate = Decimal(hourly_rate) if hourly_rate else None if hourly_rate is not None and hourly_rate < 0: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid hourly rate format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid hourly rate format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + try: estimated_hours = float(estimated_hours) if estimated_hours else None if estimated_hours is not None and estimated_hours < 0: raise ValueError except ValueError: - flash(_('Invalid estimated hours format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid estimated hours format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + try: - tax_rate = Decimal(tax_rate) if tax_rate else Decimal('0') + tax_rate = Decimal(tax_rate) if tax_rate else Decimal("0") if tax_rate < 0 or tax_rate > 100: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid tax rate format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid tax rate format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Parse valid_until date valid_until_date = None if valid_until: try: - valid_until_date = datetime.strptime(valid_until, '%Y-%m-%d').date() + valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format for valid until'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid date format for valid until"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Generate quote number quote_number = Quote.generate_quote_number() - + # Create quote quote = Quote( quote_number=quote_number, @@ -131,128 +129,124 @@ def create_quote(): currency_code=currency_code, valid_until=valid_until_date, notes=notes, - terms=terms + terms=terms, ) - + db.session.add(quote) db.session.flush() # Get quote ID for items - + # Process line items if provided - item_descriptions = request.form.getlist('item_description[]') - item_quantities = request.form.getlist('item_quantity[]') - item_prices = request.form.getlist('item_price[]') - item_units = request.form.getlist('item_unit[]') - + item_descriptions = request.form.getlist("item_description[]") + item_quantities = request.form.getlist("item_quantity[]") + item_prices = request.form.getlist("item_price[]") + item_units = request.form.getlist("item_unit[]") + for desc, qty, price, unit in zip(item_descriptions, item_quantities, item_prices, item_units): if desc.strip(): try: item = QuoteItem( quote_id=quote.id, description=desc.strip(), - quantity=Decimal(qty) if qty else Decimal('1'), - unit_price=Decimal(price) if price else Decimal('0'), - unit=unit.strip() if unit else None + quantity=Decimal(qty) if qty else Decimal("1"), + unit_price=Decimal(price) if price else Decimal("0"), + unit=unit.strip() if unit else None, ) db.session.add(item) except (ValueError, InvalidOperation): pass # Skip invalid items - + quote.calculate_totals() - - if not safe_commit('create_quote', {'title': title, 'client_id': client_id}): - flash(_('Could not create quote due to a database error. Please check server logs.'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + + if not safe_commit("create_quote", {"title": title, "client_id": client_id}): + flash(_("Could not create quote due to a database error. Please check server logs."), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Log event - log_event("quote.created", - user_id=current_user.id, - quote_id=quote.id, - quote_title=title, - client_id=client_id) - track_event(current_user.id, "quote.created", { - "quote_id": quote.id, - "quote_title": title, - "client_id": client_id - }) - - flash(_('Quote created successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote.id)) - - return render_template('quotes/create.html', clients=Client.get_active_clients()) - -@quotes_bp.route('/quotes/') + log_event("quote.created", user_id=current_user.id, quote_id=quote.id, quote_title=title, client_id=client_id) + track_event( + current_user.id, "quote.created", {"quote_id": quote.id, "quote_title": title, "client_id": client_id} + ) + + flash(_("Quote created successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote.id)) + + return render_template("quotes/create.html", clients=Client.get_active_clients()) + + +@quotes_bp.route("/quotes/") @login_required def view_quote(quote_id): """View quote details""" quote = Quote.query.get_or_404(quote_id) - return render_template('quotes/view.html', quote=quote) + return render_template("quotes/view.html", quote=quote) -@quotes_bp.route('/quotes//edit', methods=['GET', 'POST']) + +@quotes_bp.route("/quotes//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def edit_quote(quote_id): """Edit an quote""" quote = Quote.query.get_or_404(quote_id) - + # Only allow editing draft quotes - if quote.status != 'draft': - flash(_('Only draft quotes can be edited'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if request.method == 'POST': - title = request.form.get('title', '').strip() - description = request.form.get('description', '').strip() - total_amount = request.form.get('total_amount', '').strip() - hourly_rate = request.form.get('hourly_rate', '').strip() - estimated_hours = request.form.get('estimated_hours', '').strip() - tax_rate = request.form.get('tax_rate', '0').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - valid_until = request.form.get('valid_until', '').strip() - notes = request.form.get('notes', '').strip() - terms = request.form.get('terms', '').strip() - + if quote.status != "draft": + flash(_("Only draft quotes can be edited"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if request.method == "POST": + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + total_amount = request.form.get("total_amount", "").strip() + hourly_rate = request.form.get("hourly_rate", "").strip() + estimated_hours = request.form.get("estimated_hours", "").strip() + tax_rate = request.form.get("tax_rate", "0").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + valid_until = request.form.get("valid_until", "").strip() + notes = request.form.get("notes", "").strip() + terms = request.form.get("terms", "").strip() + # Validate amounts try: total_amount = Decimal(total_amount) if total_amount else None if total_amount is not None and total_amount < 0: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid total amount format'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid total amount format"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + try: hourly_rate = Decimal(hourly_rate) if hourly_rate else None if hourly_rate is not None and hourly_rate < 0: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid hourly rate format'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid hourly rate format"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + try: estimated_hours = float(estimated_hours) if estimated_hours else None if estimated_hours is not None and estimated_hours < 0: raise ValueError except ValueError: - flash(_('Invalid estimated hours format'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid estimated hours format"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + try: - tax_rate = Decimal(tax_rate) if tax_rate else Decimal('0') + tax_rate = Decimal(tax_rate) if tax_rate else Decimal("0") if tax_rate < 0 or tax_rate > 100: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid tax rate format'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid tax rate format"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + # Parse valid_until date valid_until_date = None if valid_until: try: - valid_until_date = datetime.strptime(valid_until, '%Y-%m-%d').date() + valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format for valid until'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid date format for valid until"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + # Update quote quote.title = title quote.description = description.strip() if description else None @@ -264,74 +258,64 @@ def edit_quote(quote_id): quote.valid_until = valid_until_date quote.notes = notes.strip() if notes else None quote.terms = terms.strip() if terms else None - - if not safe_commit('edit_quote', {'quote_id': quote_id}): - flash(_('Could not update quote due to a database error. Please check server logs.'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - - log_event("quote.updated", - user_id=current_user.id, - quote_id=quote.id, - quote_title=title) - track_event(current_user.id, "quote.updated", { - "quote_id": quote.id, - "quote_title": title - }) - - flash(_('Quote updated successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - -@quotes_bp.route('/quotes//send', methods=['POST']) + + if not safe_commit("edit_quote", {"quote_id": quote_id}): + flash(_("Could not update quote due to a database error. Please check server logs."), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + + log_event("quote.updated", user_id=current_user.id, quote_id=quote.id, quote_title=title) + track_event(current_user.id, "quote.updated", {"quote_id": quote.id, "quote_title": title}) + + flash(_("Quote updated successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + + +@quotes_bp.route("/quotes//send", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def send_quote(quote_id): """Send an quote to the client""" quote = Quote.query.get_or_404(quote_id) - - if quote.status != 'draft': - flash(_('Only draft quotes can be sent'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + + if quote.status != "draft": + flash(_("Only draft quotes can be sent"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + quote.send() - - if not safe_commit('send_quote', {'quote_id': quote_id}): - flash(_('Could not send quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.sent", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title) - track_event(current_user.id, "quote.sent", { - "quote_id": quote.id, - "quote_title": quote.title - }) - - flash(_('Quote sent successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - -@quotes_bp.route('/quotes//accept', methods=['GET', 'POST']) + + if not safe_commit("send_quote", {"quote_id": quote_id}): + flash(_("Could not send quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event("quote.sent", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title) + track_event(current_user.id, "quote.sent", {"quote_id": quote.id, "quote_title": quote.title}) + + flash(_("Quote sent successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes//accept", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('accept_quotes') +@admin_or_permission_required("accept_quotes") def accept_quote(quote_id): """Accept an quote and create a project""" quote = Quote.query.get_or_404(quote_id) - + if not quote.can_be_accepted: - flash(_('This quote cannot be accepted'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if request.method == 'POST': + flash(_("This quote cannot be accepted"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if request.method == "POST": # Create project from quote - project_name = request.form.get('project_name', quote.title).strip() + project_name = request.form.get("project_name", quote.title).strip() if not project_name: project_name = quote.title - + # Use quote's budget as project budget budget_amount = quote.total_amount - + # Create project project = Project( name=project_name, @@ -341,101 +325,88 @@ def accept_quote(quote_id): hourly_rate=quote.hourly_rate, budget_amount=budget_amount, quote_id=quote.id, - status='active' + status="active", ) - + db.session.add(project) - + # Accept the quote try: db.session.flush() # Get project ID quote.accept(current_user.id, project.id) except ValueError as e: - flash(_('Could not accept quote: %(error)s', error=str(e)), 'error') + flash(_("Could not accept quote: %(error)s", error=str(e)), "error") db.session.rollback() - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if not safe_commit('accept_quote', {'quote_id': quote_id, 'project_id': project.id}): - flash(_('Could not accept quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.accepted", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title, - project_id=project.id) - track_event(current_user.id, "quote.accepted", { - "quote_id": quote.id, - "quote_title": quote.title, - "project_id": project.id - }) - - flash(_('Quote accepted and project created successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('quotes/accept.html', quote=quote) - -@quotes_bp.route('/quotes//reject', methods=['POST']) + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if not safe_commit("accept_quote", {"quote_id": quote_id, "project_id": project.id}): + flash(_("Could not accept quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event( + "quote.accepted", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title, project_id=project.id + ) + track_event( + current_user.id, + "quote.accepted", + {"quote_id": quote.id, "quote_title": quote.title, "project_id": project.id}, + ) + + flash(_("Quote accepted and project created successfully"), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + + return render_template("quotes/accept.html", quote=quote) + + +@quotes_bp.route("/quotes//reject", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def reject_quote(quote_id): """Reject an quote""" quote = Quote.query.get_or_404(quote_id) - - if quote.status not in ['sent', 'draft']: - flash(_('This quote cannot be rejected'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + + if quote.status not in ["sent", "draft"]: + flash(_("This quote cannot be rejected"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + try: quote.reject() except ValueError as e: - flash(_('Could not reject quote: %(error)s', error=str(e)), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if not safe_commit('reject_quote', {'quote_id': quote_id}): - flash(_('Could not reject quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.rejected", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title) - track_event(current_user.id, "quote.rejected", { - "quote_id": quote.id, - "quote_title": quote.title - }) - - flash(_('Quote rejected'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - -@quotes_bp.route('/quotes//delete', methods=['POST']) + flash(_("Could not reject quote: %(error)s", error=str(e)), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if not safe_commit("reject_quote", {"quote_id": quote_id}): + flash(_("Could not reject quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event("quote.rejected", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title) + track_event(current_user.id, "quote.rejected", {"quote_id": quote.id, "quote_title": quote.title}) + + flash(_("Quote rejected"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes//delete", methods=["POST"]) @login_required -@admin_or_permission_required('delete_quotes') +@admin_or_permission_required("delete_quotes") def delete_quote(quote_id): """Delete an quote""" quote = Quote.query.get_or_404(quote_id) - + # Only allow deleting draft or rejected quotes - if quote.status not in ['draft', 'rejected']: - flash(_('Only draft or rejected quotes can be deleted'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + if quote.status not in ["draft", "rejected"]: + flash(_("Only draft or rejected quotes can be deleted"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + quote_title = quote.title db.session.delete(quote) - - if not safe_commit('delete_quote', {'quote_id': quote_id}): - flash(_('Could not delete quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.deleted", - user_id=current_user.id, - quote_id=quote_id, - quote_title=quote_title) - track_event(current_user.id, "quote.deleted", { - "quote_id": quote_id, - "quote_title": quote_title - }) - - flash(_('Quote deleted successfully'), 'success') - return redirect(url_for('quotes.list_quotes')) + if not safe_commit("delete_quote", {"quote_id": quote_id}): + flash(_("Could not delete quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event("quote.deleted", user_id=current_user.id, quote_id=quote_id, quote_title=quote_title) + track_event(current_user.id, "quote.deleted", {"quote_id": quote_id, "quote_title": quote_title}) + + flash(_("Quote deleted successfully"), "success") + return redirect(url_for("quotes.list_quotes")) diff --git a/app/routes/payment_gateways.py b/app/routes/payment_gateways.py index 73242abb..f640f7d3 100644 --- a/app/routes/payment_gateways.py +++ b/app/routes/payment_gateways.py @@ -13,161 +13,157 @@ import json import os -payment_gateways_bp = Blueprint('payment_gateways', __name__) +payment_gateways_bp = Blueprint("payment_gateways", __name__) -@payment_gateways_bp.route('/payment-gateways') +@payment_gateways_bp.route("/payment-gateways") @login_required -@admin_or_permission_required('admin_access') +@admin_or_permission_required("admin_access") def list_gateways(): """List payment gateways""" gateways = PaymentGateway.query.all() - return render_template('payment_gateways/list.html', gateways=gateways) + return render_template("payment_gateways/list.html", gateways=gateways) -@payment_gateways_bp.route('/payment-gateways/create', methods=['GET', 'POST']) +@payment_gateways_bp.route("/payment-gateways/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('admin_access') +@admin_or_permission_required("admin_access") def create_gateway(): """Create a payment gateway""" - if request.method == 'POST': - name = request.form.get('name', '').strip() - provider = request.form.get('provider', '').strip() - is_test_mode = request.form.get('is_test_mode', 'false').lower() == 'true' - + if request.method == "POST": + name = request.form.get("name", "").strip() + provider = request.form.get("provider", "").strip() + is_test_mode = request.form.get("is_test_mode", "false").lower() == "true" + # Get config based on provider config = {} - if provider == 'stripe': + if provider == "stripe": config = { - 'api_key': request.form.get('api_key', '').strip(), - 'publishable_key': request.form.get('publishable_key', '').strip(), - 'webhook_secret': request.form.get('webhook_secret', '').strip() + "api_key": request.form.get("api_key", "").strip(), + "publishable_key": request.form.get("publishable_key", "").strip(), + "webhook_secret": request.form.get("webhook_secret", "").strip(), } - elif provider == 'paypal': + elif provider == "paypal": config = { - 'client_id': request.form.get('client_id', '').strip(), - 'client_secret': request.form.get('client_secret', '').strip() + "client_id": request.form.get("client_id", "").strip(), + "client_secret": request.form.get("client_secret", "").strip(), } - + service = PaymentGatewayService() - result = service.create_gateway( - name=name, - provider=provider, - config=config, - is_test_mode=is_test_mode - ) - - if result['success']: - flash(_('Payment gateway created successfully.'), 'success') - return redirect(url_for('payment_gateways.list_gateways')) + result = service.create_gateway(name=name, provider=provider, config=config, is_test_mode=is_test_mode) + + if result["success"]: + flash(_("Payment gateway created successfully."), "success") + return redirect(url_for("payment_gateways.list_gateways")) else: - flash(result['message'], 'error') - - return render_template('payment_gateways/create.html') + flash(result["message"], "error") + + return render_template("payment_gateways/create.html") -@payment_gateways_bp.route('/invoices//pay', methods=['GET', 'POST']) +@payment_gateways_bp.route("/invoices//pay", methods=["GET", "POST"]) @login_required def pay_invoice(invoice_id): """Pay an invoice""" invoice = Invoice.query.get_or_404(invoice_id) - + # Get active payment gateway service = PaymentGatewayService() - gateway = service.get_active_gateway(provider='stripe') - + gateway = service.get_active_gateway(provider="stripe") + if not gateway: - flash(_('No payment gateway configured. Please contact an administrator.'), 'error') - return redirect(url_for('invoices.view_invoice', invoice_id=invoice_id)) - - if request.method == 'POST': + flash(_("No payment gateway configured. Please contact an administrator."), "error") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) + + if request.method == "POST": # Process payment amount = Decimal(str(invoice.total_amount)) - + # For Stripe, create payment intent - if gateway.provider == 'stripe': + if gateway.provider == "stripe": # Get API key from config import json + config = json.loads(gateway.config) if isinstance(gateway.config, str) else gateway.config - api_key = config.get('api_key') or os.getenv('STRIPE_API_KEY') - + api_key = config.get("api_key") or os.getenv("STRIPE_API_KEY") + if not api_key: - flash(_('Stripe API key not configured.'), 'error') - return redirect(url_for('invoices.view_invoice', invoice_id=invoice_id)) - + flash(_("Stripe API key not configured."), "error") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) + stripe_integration = StripeIntegration(api_key) - + # Create checkout session - success_url = request.url_root.rstrip('/') + url_for('payment_gateways.payment_success', invoice_id=invoice_id) - cancel_url = request.url_root.rstrip('/') + url_for('invoices.view_invoice', invoice_id=invoice_id) - + success_url = request.url_root.rstrip("/") + url_for( + "payment_gateways.payment_success", invoice_id=invoice_id + ) + cancel_url = request.url_root.rstrip("/") + url_for("invoices.view_invoice", invoice_id=invoice_id) + result = stripe_integration.create_checkout_session( invoice_id=invoice_id, amount=amount, currency=invoice.currency_code, success_url=success_url, cancel_url=cancel_url, - description=f'Invoice {invoice.invoice_number}' + description=f"Invoice {invoice.invoice_number}", ) - - if result['success']: - return redirect(result['url']) + + if result["success"]: + return redirect(result["url"]) else: - flash(result['message'], 'error') + flash(result["message"], "error") else: - flash(_('Payment gateway not yet supported.'), 'error') - - return render_template('payment_gateways/pay.html', invoice=invoice, gateway=gateway) + flash(_("Payment gateway not yet supported."), "error") + + return render_template("payment_gateways/pay.html", invoice=invoice, gateway=gateway) -@payment_gateways_bp.route('/payment-gateways/stripe/webhook', methods=['POST']) +@payment_gateways_bp.route("/payment-gateways/stripe/webhook", methods=["POST"]) def stripe_webhook(): """Handle Stripe webhook""" payload = request.data - sig_header = request.headers.get('Stripe-Signature') - + sig_header = request.headers.get("Stripe-Signature") + # Get webhook secret - gateway = PaymentGatewayService().get_active_gateway(provider='stripe') + gateway = PaymentGatewayService().get_active_gateway(provider="stripe") if not gateway: - return jsonify({'error': 'Gateway not found'}), 404 - + return jsonify({"error": "Gateway not found"}), 404 + import json + config = json.loads(gateway.config) if isinstance(gateway.config, str) else gateway.config - webhook_secret = config.get('webhook_secret') or os.getenv('STRIPE_WEBHOOK_SECRET') - + webhook_secret = config.get("webhook_secret") or os.getenv("STRIPE_WEBHOOK_SECRET") + if not webhook_secret: - return jsonify({'error': 'Webhook secret not configured'}), 500 - - stripe_integration = StripeIntegration(gateway.config.get('api_key')) + return jsonify({"error": "Webhook secret not configured"}), 500 + + stripe_integration = StripeIntegration(gateway.config.get("api_key")) event = stripe_integration.verify_webhook(payload, sig_header, webhook_secret) - + if not event: - return jsonify({'error': 'Invalid signature'}), 400 - + return jsonify({"error": "Invalid signature"}), 400 + # Handle event service = PaymentGatewayService() - - if event['type'] == 'payment_intent.succeeded': - payment_intent = event['data']['object'] - transaction_id = payment_intent['id'] - invoice_id = int(payment_intent['metadata'].get('invoice_id', 0)) - + + if event["type"] == "payment_intent.succeeded": + payment_intent = event["data"]["object"] + transaction_id = payment_intent["id"] + invoice_id = int(payment_intent["metadata"].get("invoice_id", 0)) + if invoice_id: - amount = Decimal(str(payment_intent['amount'])) / 100 + amount = Decimal(str(payment_intent["amount"])) / 100 service.update_transaction_status( - transaction_id=transaction_id, - status='completed', - gateway_response=payment_intent + transaction_id=transaction_id, status="completed", gateway_response=payment_intent ) - - return jsonify({'status': 'success'}) + + return jsonify({"status": "success"}) -@payment_gateways_bp.route('/payment-gateways/payment-success/') +@payment_gateways_bp.route("/payment-gateways/payment-success/") @login_required def payment_success(invoice_id): """Payment success page""" invoice = Invoice.query.get_or_404(invoice_id) - flash(_('Payment processed successfully.'), 'success') - return redirect(url_for('invoices.view_invoice', invoice_id=invoice_id)) - + flash(_("Payment processed successfully."), "success") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice_id)) diff --git a/app/routes/payments.py b/app/routes/payments.py index 7cbef83f..217bbf81 100644 --- a/app/routes/payments.py +++ b/app/routes/payments.py @@ -10,187 +10,196 @@ from app.utils.db import safe_commit from app.utils.excel_export import create_payments_list_excel -payments_bp = Blueprint('payments', __name__) +payments_bp = Blueprint("payments", __name__) -@payments_bp.route('/payments') + +@payments_bp.route("/payments") @login_required def list_payments(): """List all payments""" # Get filter parameters - status_filter = request.args.get('status', '') - method_filter = request.args.get('method', '') - date_from = request.args.get('date_from', '') - date_to = request.args.get('date_to', '') - invoice_id = request.args.get('invoice_id', type=int) - + status_filter = request.args.get("status", "") + method_filter = request.args.get("method", "") + date_from = request.args.get("date_from", "") + date_to = request.args.get("date_to", "") + invoice_id = request.args.get("invoice_id", type=int) + # Base query query = Payment.query - + # Apply filters based on user role if not current_user.is_admin: # Regular users can only see payments for their own invoices query = query.join(Invoice).filter(Invoice.created_by == current_user.id) - + # Apply status filter if status_filter: query = query.filter(Payment.status == status_filter) - + # Apply payment method filter if method_filter: query = query.filter(Payment.method == method_filter) - + # Apply date range filter if date_from: try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date() + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date() query = query.filter(Payment.payment_date >= date_from_obj) except ValueError: - flash(_('Invalid from date format'), 'error') - + flash(_("Invalid from date format"), "error") + if date_to: try: - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date() + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date() query = query.filter(Payment.payment_date <= date_to_obj) except ValueError: - flash(_('Invalid to date format'), 'error') - + flash(_("Invalid to date format"), "error") + # Apply invoice filter if invoice_id: query = query.filter(Payment.invoice_id == invoice_id) - + # Get payments payments = query.order_by(Payment.payment_date.desc(), Payment.created_at.desc()).all() - + # Calculate summary statistics total_payments = len(payments) total_amount = sum(payment.amount for payment in payments) - total_fees = sum(payment.gateway_fee or Decimal('0') for payment in payments) + total_fees = sum(payment.gateway_fee or Decimal("0") for payment in payments) total_net = sum(payment.net_amount or payment.amount for payment in payments) - + # Status breakdown - completed_payments = [p for p in payments if p.status == 'completed'] - pending_payments = [p for p in payments if p.status == 'pending'] - failed_payments = [p for p in payments if p.status == 'failed'] - refunded_payments = [p for p in payments if p.status == 'refunded'] - + completed_payments = [p for p in payments if p.status == "completed"] + pending_payments = [p for p in payments if p.status == "pending"] + failed_payments = [p for p in payments if p.status == "failed"] + refunded_payments = [p for p in payments if p.status == "refunded"] + summary = { - 'total_payments': total_payments, - 'total_amount': float(total_amount), - 'total_fees': float(total_fees), - 'total_net': float(total_net), - 'completed_count': len(completed_payments), - 'completed_amount': float(sum(p.amount for p in completed_payments)), - 'pending_count': len(pending_payments), - 'pending_amount': float(sum(p.amount for p in pending_payments)), - 'failed_count': len(failed_payments), - 'refunded_count': len(refunded_payments), - 'refunded_amount': float(sum(p.amount for p in refunded_payments)) + "total_payments": total_payments, + "total_amount": float(total_amount), + "total_fees": float(total_fees), + "total_net": float(total_net), + "completed_count": len(completed_payments), + "completed_amount": float(sum(p.amount for p in completed_payments)), + "pending_count": len(pending_payments), + "pending_amount": float(sum(p.amount for p in pending_payments)), + "failed_count": len(failed_payments), + "refunded_count": len(refunded_payments), + "refunded_amount": float(sum(p.amount for p in refunded_payments)), } - + # Get unique payment methods for filter dropdown payment_methods = db.session.query(Payment.method).distinct().filter(Payment.method.isnot(None)).all() payment_methods = [method[0] for method in payment_methods] - + # Track event - track_event(current_user.id, 'payments_viewed', properties={ - 'total_payments': total_payments, - 'filters_applied': bool(status_filter or method_filter or date_from or date_to or invoice_id) - }) - - return render_template('payments/list.html', - payments=payments, - summary=summary, - payment_methods=payment_methods, - filters={ - 'status': status_filter, - 'method': method_filter, - 'date_from': date_from, - 'date_to': date_to, - 'invoice_id': invoice_id - }) - -@payments_bp.route('/payments/') + track_event( + current_user.id, + "payments_viewed", + properties={ + "total_payments": total_payments, + "filters_applied": bool(status_filter or method_filter or date_from or date_to or invoice_id), + }, + ) + + return render_template( + "payments/list.html", + payments=payments, + summary=summary, + payment_methods=payment_methods, + filters={ + "status": status_filter, + "method": method_filter, + "date_from": date_from, + "date_to": date_to, + "invoice_id": invoice_id, + }, + ) + + +@payments_bp.route("/payments/") @login_required def view_payment(payment_id): """View payment details""" payment = Payment.query.get_or_404(payment_id) - + # Check access permissions if not current_user.is_admin and payment.invoice.created_by != current_user.id: - flash(_('You do not have permission to view this payment'), 'error') - return redirect(url_for('payments.list_payments')) - - return render_template('payments/view.html', payment=payment) + flash(_("You do not have permission to view this payment"), "error") + return redirect(url_for("payments.list_payments")) + + return render_template("payments/view.html", payment=payment) -@payments_bp.route('/payments/create', methods=['GET', 'POST']) + +@payments_bp.route("/payments/create", methods=["GET", "POST"]) @login_required def create_payment(): """Create a new payment""" - if request.method == 'POST': + if request.method == "POST": # Get form data - invoice_id = request.form.get('invoice_id', type=int) - amount_str = request.form.get('amount', '0').strip() - currency = request.form.get('currency', '').strip() - payment_date_str = request.form.get('payment_date', '').strip() - method = request.form.get('method', '').strip() - reference = request.form.get('reference', '').strip() - notes = request.form.get('notes', '').strip() - status = request.form.get('status', 'completed').strip() - gateway_transaction_id = request.form.get('gateway_transaction_id', '').strip() - gateway_fee_str = request.form.get('gateway_fee', '0').strip() - + invoice_id = request.form.get("invoice_id", type=int) + amount_str = request.form.get("amount", "0").strip() + currency = request.form.get("currency", "").strip() + payment_date_str = request.form.get("payment_date", "").strip() + method = request.form.get("method", "").strip() + reference = request.form.get("reference", "").strip() + notes = request.form.get("notes", "").strip() + status = request.form.get("status", "completed").strip() + gateway_transaction_id = request.form.get("gateway_transaction_id", "").strip() + gateway_fee_str = request.form.get("gateway_fee", "0").strip() + # Validate required fields if not invoice_id or not amount_str or not payment_date_str: - flash(_('Invoice, amount, and payment date are required'), 'error') + flash(_("Invoice, amount, and payment date are required"), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) - + return render_template("payments/create.html", invoices=invoices) + # Get invoice invoice = Invoice.query.get(invoice_id) if not invoice: - flash(_('Selected invoice not found'), 'error') + flash(_("Selected invoice not found"), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) - + return render_template("payments/create.html", invoices=invoices) + # Check access permissions if not current_user.is_admin and invoice.created_by != current_user.id: - flash(_('You do not have permission to add payments to this invoice'), 'error') - return redirect(url_for('payments.list_payments')) - + flash(_("You do not have permission to add payments to this invoice"), "error") + return redirect(url_for("payments.list_payments")) + # Validate and parse amount try: amount = Decimal(amount_str) if amount <= 0: - flash(_('Payment amount must be greater than zero'), 'error') + flash(_("Payment amount must be greater than zero"), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) + return render_template("payments/create.html", invoices=invoices) except (ValueError, InvalidOperation): - flash(_('Invalid payment amount'), 'error') + flash(_("Invalid payment amount"), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) - + return render_template("payments/create.html", invoices=invoices) + # Validate and parse payment date try: - payment_date = datetime.strptime(payment_date_str, '%Y-%m-%d').date() + payment_date = datetime.strptime(payment_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid payment date format'), 'error') + flash(_("Invalid payment date format"), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) - + return render_template("payments/create.html", invoices=invoices) + # Parse gateway fee if provided gateway_fee = None if gateway_fee_str: try: gateway_fee = Decimal(gateway_fee_str) if gateway_fee < 0: - flash(_('Gateway fee cannot be negative'), 'error') + flash(_("Gateway fee cannot be negative"), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) + return render_template("payments/create.html", invoices=invoices) except (ValueError, InvalidOperation): - flash(_('Invalid gateway fee amount'), 'error') + flash(_("Invalid gateway fee amount"), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) - + return render_template("payments/create.html", invoices=invoices) + # Create payment payment = Payment( invoice_id=invoice_id, @@ -205,28 +214,28 @@ def create_payment(): gateway_transaction_id=gateway_transaction_id if gateway_transaction_id else None, gateway_fee=gateway_fee, created_at=datetime.utcnow(), - updated_at=datetime.utcnow() + updated_at=datetime.utcnow(), ) - + # Calculate net amount payment.calculate_net_amount() - + db.session.add(payment) - + # Update invoice payment tracking if payment is completed - if status == 'completed': - invoice.amount_paid = (invoice.amount_paid or Decimal('0')) + amount + if status == "completed": + invoice.amount_paid = (invoice.amount_paid or Decimal("0")) + amount invoice.update_payment_status() - + # Update invoice status if fully paid - if invoice.payment_status == 'fully_paid': - invoice.status = 'paid' - + if invoice.payment_status == "fully_paid": + invoice.status = "paid" + # Reduce stock when invoice is fully paid (if configured) from app.models import StockMovement, StockReservation import os - - reduce_on_paid = os.getenv('INVENTORY_REDUCE_ON_INVOICE_PAID', 'false').lower() == 'true' + + reduce_on_paid = os.getenv("INVENTORY_REDUCE_ON_INVOICE_PAID", "false").lower() == "true" if reduce_on_paid: for item in invoice.items: if item.is_stock_item and item.stock_item_id and item.warehouse_id: @@ -235,52 +244,56 @@ def create_payment(): reservation = StockReservation.query.filter_by( stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, - reservation_type='invoice', + reservation_type="invoice", reservation_id=invoice.id, - status='reserved' + status="reserved", ).first() - + if reservation: reservation.fulfill() - + # Create stock movement (sale) StockMovement.record_movement( - movement_type='sale', + movement_type="sale", stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, quantity=-item.quantity, # Negative for removal moved_by=current_user.id, - reference_type='invoice', + reference_type="invoice", reference_id=invoice.id, unit_cost=item.stock_item.default_cost if item.stock_item else None, - reason=f'Invoice {invoice.invoice_number} payment', - update_stock=True + reason=f"Invoice {invoice.invoice_number} payment", + update_stock=True, ) except Exception as e: pass # Don't fail payment creation on stock errors - - if not safe_commit('create_payment', {'invoice_id': invoice_id, 'amount': float(amount)}): - flash(_('Could not create payment due to a database error. Please check server logs.'), 'error') + + if not safe_commit("create_payment", {"invoice_id": invoice_id, "amount": float(amount)}): + flash(_("Could not create payment due to a database error. Please check server logs."), "error") invoices = get_user_invoices() - return render_template('payments/create.html', invoices=invoices) - + return render_template("payments/create.html", invoices=invoices) + # Track event - track_event(current_user.id, 'payment_created', properties={ - 'payment_id': payment.id, - 'invoice_id': invoice_id, - 'amount': float(amount), - 'method': method, - 'status': status - }) - - flash(f'Payment of {amount} {currency or invoice.currency_code} recorded successfully', 'success') - return redirect(url_for('payments.view_payment', payment_id=payment.id)) - + track_event( + current_user.id, + "payment_created", + properties={ + "payment_id": payment.id, + "invoice_id": invoice_id, + "amount": float(amount), + "method": method, + "status": status, + }, + ) + + flash(f"Payment of {amount} {currency or invoice.currency_code} recorded successfully", "success") + return redirect(url_for("payments.view_payment", payment_id=payment.id)) + # GET request - show form invoices = get_user_invoices() - + # Pre-select invoice if provided in query params - selected_invoice_id = request.args.get('invoice_id', type=int) + selected_invoice_id = request.args.get("invoice_id", type=int) selected_invoice = None if selected_invoice_id: selected_invoice = Invoice.query.get(selected_invoice_id) @@ -288,70 +301,68 @@ def create_payment(): pass else: selected_invoice = None - - today = date.today().strftime('%Y-%m-%d') - - return render_template('payments/create.html', - invoices=invoices, - selected_invoice=selected_invoice, - today=today) - -@payments_bp.route('/payments//edit', methods=['GET', 'POST']) + + today = date.today().strftime("%Y-%m-%d") + + return render_template("payments/create.html", invoices=invoices, selected_invoice=selected_invoice, today=today) + + +@payments_bp.route("/payments//edit", methods=["GET", "POST"]) @login_required def edit_payment(payment_id): """Edit payment""" payment = Payment.query.get_or_404(payment_id) - + # Check access permissions if not current_user.is_admin and payment.invoice.created_by != current_user.id: - flash(_('You do not have permission to edit this payment'), 'error') - return redirect(url_for('payments.list_payments')) - - if request.method == 'POST': + flash(_("You do not have permission to edit this payment"), "error") + return redirect(url_for("payments.list_payments")) + + if request.method == "POST": # Store old amount for invoice update old_amount = payment.amount old_status = payment.status - + # Get form data - amount_str = request.form.get('amount', '0').strip() - currency = request.form.get('currency', '').strip() - payment_date_str = request.form.get('payment_date', '').strip() - method = request.form.get('method', '').strip() - reference = request.form.get('reference', '').strip() - notes = request.form.get('notes', '').strip() - status = request.form.get('status', 'completed').strip() - gateway_transaction_id = request.form.get('gateway_transaction_id', '').strip() - gateway_fee_str = request.form.get('gateway_fee', '0').strip() - + amount_str = request.form.get("amount", "0").strip() + currency = request.form.get("currency", "").strip() + payment_date_str = request.form.get("payment_date", "").strip() + method = request.form.get("method", "").strip() + reference = request.form.get("reference", "").strip() + notes = request.form.get("notes", "").strip() + status = request.form.get("status", "completed").strip() + gateway_transaction_id = request.form.get("gateway_transaction_id", "").strip() + gateway_fee_str = request.form.get("gateway_fee", "0").strip() + # Validate and parse amount try: amount = Decimal(amount_str) if amount <= 0: - flash(_('Payment amount must be greater than zero'), 'error') - return render_template('payments/edit.html', payment=payment) + flash(_("Payment amount must be greater than zero"), "error") + return render_template("payments/edit.html", payment=payment) except (ValueError, InvalidOperation): - flash(_('Invalid payment amount'), 'error') - return render_template('payments/edit.html', payment=payment) - + flash(_("Invalid payment amount"), "error") + return render_template("payments/edit.html", payment=payment) + # Validate and parse payment date try: - payment_date = datetime.strptime(payment_date_str, '%Y-%m-%d').date() + payment_date = datetime.strptime(payment_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid payment date format'), 'error') - return render_template('payments/edit.html', payment=payment) - + flash(_("Invalid payment date format"), "error") + return render_template("payments/edit.html", payment=payment) + # Parse gateway fee if provided gateway_fee = None if gateway_fee_str: try: gateway_fee = Decimal(gateway_fee_str) if gateway_fee < 0: - flash(_('Gateway fee cannot be negative'), 'error') - return render_template('payments/edit.html', payment=payment) + flash(_("Gateway fee cannot be negative"), "error") + return render_template("payments/edit.html", payment=payment) except (ValueError, InvalidOperation): - flash(_('Invalid gateway fee amount'), 'error') - return render_template('payments/edit.html', payment=payment) - + flash(_("Invalid gateway fee amount"), "error") + return render_template("payments/edit.html", payment=payment) + # Update payment payment.amount = amount payment.currency = currency if currency else payment.invoice.currency_code @@ -363,219 +374,223 @@ def edit_payment(payment_id): payment.gateway_transaction_id = gateway_transaction_id if gateway_transaction_id else None payment.gateway_fee = gateway_fee payment.updated_at = datetime.utcnow() - + # Calculate net amount payment.calculate_net_amount() - + # Update invoice payment tracking invoice = payment.invoice - + # Adjust invoice amount_paid based on old and new amounts and statuses - if old_status == 'completed': - invoice.amount_paid = (invoice.amount_paid or Decimal('0')) - old_amount - - if status == 'completed': - invoice.amount_paid = (invoice.amount_paid or Decimal('0')) + amount - + if old_status == "completed": + invoice.amount_paid = (invoice.amount_paid or Decimal("0")) - old_amount + + if status == "completed": + invoice.amount_paid = (invoice.amount_paid or Decimal("0")) + amount + invoice.update_payment_status() - + # Update invoice status - if invoice.payment_status == 'fully_paid': - invoice.status = 'paid' - elif invoice.status == 'paid' and invoice.payment_status != 'fully_paid': - invoice.status = 'sent' - - if not safe_commit('edit_payment', {'payment_id': payment_id}): - flash(_('Could not update payment due to a database error. Please check server logs.'), 'error') - return render_template('payments/edit.html', payment=payment) - + if invoice.payment_status == "fully_paid": + invoice.status = "paid" + elif invoice.status == "paid" and invoice.payment_status != "fully_paid": + invoice.status = "sent" + + if not safe_commit("edit_payment", {"payment_id": payment_id}): + flash(_("Could not update payment due to a database error. Please check server logs."), "error") + return render_template("payments/edit.html", payment=payment) + # Track event - track_event(current_user.id, 'payment_updated', properties={ - 'payment_id': payment.id, - 'amount': float(amount), - 'status': status - }) - - flash(_('Payment updated successfully'), 'success') - return redirect(url_for('payments.view_payment', payment_id=payment.id)) - + track_event( + current_user.id, + "payment_updated", + properties={"payment_id": payment.id, "amount": float(amount), "status": status}, + ) + + flash(_("Payment updated successfully"), "success") + return redirect(url_for("payments.view_payment", payment_id=payment.id)) + # GET request - show edit form - return render_template('payments/edit.html', payment=payment) + return render_template("payments/edit.html", payment=payment) -@payments_bp.route('/payments//delete', methods=['POST']) + +@payments_bp.route("/payments//delete", methods=["POST"]) @login_required def delete_payment(payment_id): """Delete payment""" payment = Payment.query.get_or_404(payment_id) - + # Check access permissions if not current_user.is_admin and payment.invoice.created_by != current_user.id: - flash(_('You do not have permission to delete this payment'), 'error') - return redirect(url_for('payments.list_payments')) - + flash(_("You do not have permission to delete this payment"), "error") + return redirect(url_for("payments.list_payments")) + # Store info for invoice update invoice = payment.invoice amount = payment.amount status = payment.status - + # Update invoice payment tracking if payment was completed - if status == 'completed': - invoice.amount_paid = max(Decimal('0'), (invoice.amount_paid or Decimal('0')) - amount) + if status == "completed": + invoice.amount_paid = max(Decimal("0"), (invoice.amount_paid or Decimal("0")) - amount) invoice.update_payment_status() - + # Update invoice status if no longer paid - if invoice.status == 'paid' and invoice.payment_status != 'fully_paid': - invoice.status = 'sent' - + if invoice.status == "paid" and invoice.payment_status != "fully_paid": + invoice.status = "sent" + db.session.delete(payment) - - if not safe_commit('delete_payment', {'payment_id': payment_id}): - flash(_('Could not delete payment due to a database error. Please check server logs.'), 'error') - return redirect(url_for('payments.view_payment', payment_id=payment_id)) - + + if not safe_commit("delete_payment", {"payment_id": payment_id}): + flash(_("Could not delete payment due to a database error. Please check server logs."), "error") + return redirect(url_for("payments.view_payment", payment_id=payment_id)) + # Track event - track_event(current_user.id, 'payment_deleted', properties={ - 'payment_id': payment_id, - 'invoice_id': invoice.id - }) - - flash(_('Payment deleted successfully'), 'success') - return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id)) - -@payments_bp.route('/payments/bulk-delete', methods=['POST']) + track_event(current_user.id, "payment_deleted", properties={"payment_id": payment_id, "invoice_id": invoice.id}) + + flash(_("Payment deleted successfully"), "success") + return redirect(url_for("invoices.view_invoice", invoice_id=invoice.id)) + + +@payments_bp.route("/payments/bulk-delete", methods=["POST"]) @login_required def bulk_delete_payments(): """Delete multiple payments at once""" - payment_ids = request.form.getlist('payment_ids[]') - + payment_ids = request.form.getlist("payment_ids[]") + if not payment_ids: - flash(_('No payments selected for deletion'), 'warning') - return redirect(url_for('payments.list_payments')) - + flash(_("No payments selected for deletion"), "warning") + return redirect(url_for("payments.list_payments")) + deleted_count = 0 skipped_count = 0 errors = [] - + for payment_id_str in payment_ids: try: payment_id = int(payment_id_str) payment = Payment.query.get(payment_id) - + if not payment: continue - + # Check permissions if not current_user.is_admin and payment.invoice.created_by != current_user.id: skipped_count += 1 errors.append(f"Payment #{payment_id_str}: No permission") continue - + # Store info for invoice update invoice = payment.invoice amount = payment.amount status = payment.status - + # Update invoice payment tracking if payment was completed - if status == 'completed': - invoice.amount_paid = max(Decimal('0'), (invoice.amount_paid or Decimal('0')) - amount) + if status == "completed": + invoice.amount_paid = max(Decimal("0"), (invoice.amount_paid or Decimal("0")) - amount) invoice.update_payment_status() - + # Update invoice status if no longer paid - if invoice.status == 'paid' and invoice.payment_status != 'fully_paid': - invoice.status = 'sent' - + if invoice.status == "paid" and invoice.payment_status != "fully_paid": + invoice.status = "sent" + db.session.delete(payment) deleted_count += 1 - + except Exception as e: skipped_count += 1 errors.append(f"ID {payment_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: - if not safe_commit('bulk_delete_payments', {'count': deleted_count}): - flash(_('Could not delete payments due to a database error. Please check server logs.'), 'error') - return redirect(url_for('payments.list_payments')) - + if not safe_commit("bulk_delete_payments", {"count": deleted_count}): + flash(_("Could not delete payments due to a database error. Please check server logs."), "error") + return redirect(url_for("payments.list_payments")) + # Show appropriate messages if deleted_count > 0: - flash(f'Successfully deleted {deleted_count} payment{"s" if deleted_count != 1 else ""}', 'success') - + flash(f'Successfully deleted {deleted_count} payment{"s" if deleted_count != 1 else ""}', "success") + if skipped_count > 0: - flash(f'Skipped {skipped_count} payment{"s" if skipped_count != 1 else ""}: {"; ".join(errors[:3])}', 'warning') - - return redirect(url_for('payments.list_payments')) + flash(f'Skipped {skipped_count} payment{"s" if skipped_count != 1 else ""}: {"; ".join(errors[:3])}', "warning") + + return redirect(url_for("payments.list_payments")) -@payments_bp.route('/payments/bulk-status', methods=['POST']) + +@payments_bp.route("/payments/bulk-status", methods=["POST"]) @login_required def bulk_update_status(): """Update status for multiple payments at once""" - payment_ids = request.form.getlist('payment_ids[]') - new_status = request.form.get('status', '').strip() - + payment_ids = request.form.getlist("payment_ids[]") + new_status = request.form.get("status", "").strip() + if not payment_ids: - flash(_('No payments selected'), 'warning') - return redirect(url_for('payments.list_payments')) - + flash(_("No payments selected"), "warning") + return redirect(url_for("payments.list_payments")) + # Validate status - valid_statuses = ['completed', 'pending', 'failed', 'refunded'] + valid_statuses = ["completed", "pending", "failed", "refunded"] if not new_status or new_status not in valid_statuses: - flash(_('Invalid status value'), 'error') - return redirect(url_for('payments.list_payments')) - + flash(_("Invalid status value"), "error") + return redirect(url_for("payments.list_payments")) + updated_count = 0 skipped_count = 0 - + for payment_id_str in payment_ids: try: payment_id = int(payment_id_str) payment = Payment.query.get(payment_id) - + if not payment: continue - + # Check permissions if not current_user.is_admin and payment.invoice.created_by != current_user.id: skipped_count += 1 continue - + old_status = payment.status payment.status = new_status - + # Update invoice payment tracking if status changed to/from completed invoice = payment.invoice - if old_status == 'completed' and new_status != 'completed': + if old_status == "completed" and new_status != "completed": # Payment was completed but now isn't - subtract from invoice - invoice.amount_paid = max(Decimal('0'), (invoice.amount_paid or Decimal('0')) - payment.amount) + invoice.amount_paid = max(Decimal("0"), (invoice.amount_paid or Decimal("0")) - payment.amount) invoice.update_payment_status() - elif old_status != 'completed' and new_status == 'completed': + elif old_status != "completed" and new_status == "completed": # Payment is now completed - add to invoice - invoice.amount_paid = (invoice.amount_paid or Decimal('0')) + payment.amount + invoice.amount_paid = (invoice.amount_paid or Decimal("0")) + payment.amount invoice.update_payment_status() - + # Update invoice status if no longer paid - if old_status == 'completed' and new_status != 'completed': - if invoice.status == 'paid' and invoice.payment_status != 'fully_paid': - invoice.status = 'sent' - + if old_status == "completed" and new_status != "completed": + if invoice.status == "paid" and invoice.payment_status != "fully_paid": + invoice.status = "sent" + updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: - if not safe_commit('bulk_update_payment_status', {'count': updated_count, 'status': new_status}): - flash(_('Could not update payments due to a database error'), 'error') - return redirect(url_for('payments.list_payments')) - - flash(f'Successfully updated {updated_count} payment{"s" if updated_count != 1 else ""} to {new_status}', 'success') - + if not safe_commit("bulk_update_payment_status", {"count": updated_count, "status": new_status}): + flash(_("Could not update payments due to a database error"), "error") + return redirect(url_for("payments.list_payments")) + + flash( + f'Successfully updated {updated_count} payment{"s" if updated_count != 1 else ""} to {new_status}', + "success", + ) + if skipped_count > 0: - flash(f'Skipped {skipped_count} payment{"s" if skipped_count != 1 else ""} (no permission)', 'warning') - - return redirect(url_for('payments.list_payments')) + flash(f'Skipped {skipped_count} payment{"s" if skipped_count != 1 else ""} (no permission)', "warning") -@payments_bp.route('/api/payments/stats') + return redirect(url_for("payments.list_payments")) + + +@payments_bp.route("/api/payments/stats") @login_required def payment_stats(): """Get payment statistics""" @@ -583,139 +598,133 @@ def payment_stats(): query = Payment.query if not current_user.is_admin: query = query.join(Invoice).filter(Invoice.created_by == current_user.id) - + # Get date range from request - date_from = request.args.get('date_from', '') - date_to = request.args.get('date_to', '') - + date_from = request.args.get("date_from", "") + date_to = request.args.get("date_to", "") + if date_from: try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date() + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date() query = query.filter(Payment.payment_date >= date_from_obj) except ValueError: pass - + if date_to: try: - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date() + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date() query = query.filter(Payment.payment_date <= date_to_obj) except ValueError: pass - + payments = query.all() - + # Calculate statistics stats = { - 'total_payments': len(payments), - 'total_amount': float(sum(p.amount for p in payments)), - 'total_fees': float(sum(p.gateway_fee or Decimal('0') for p in payments)), - 'total_net': float(sum(p.net_amount or p.amount for p in payments)), - 'by_method': {}, - 'by_status': {}, - 'by_month': {} + "total_payments": len(payments), + "total_amount": float(sum(p.amount for p in payments)), + "total_fees": float(sum(p.gateway_fee or Decimal("0") for p in payments)), + "total_net": float(sum(p.net_amount or p.amount for p in payments)), + "by_method": {}, + "by_status": {}, + "by_month": {}, } - + # Group by payment method for payment in payments: - method = payment.method or 'Unknown' - if method not in stats['by_method']: - stats['by_method'][method] = {'count': 0, 'amount': 0} - stats['by_method'][method]['count'] += 1 - stats['by_method'][method]['amount'] += float(payment.amount) - + method = payment.method or "Unknown" + if method not in stats["by_method"]: + stats["by_method"][method] = {"count": 0, "amount": 0} + stats["by_method"][method]["count"] += 1 + stats["by_method"][method]["amount"] += float(payment.amount) + # Group by status for payment in payments: status = payment.status - if status not in stats['by_status']: - stats['by_status'][status] = {'count': 0, 'amount': 0} - stats['by_status'][status]['count'] += 1 - stats['by_status'][status]['amount'] += float(payment.amount) - + if status not in stats["by_status"]: + stats["by_status"][status] = {"count": 0, "amount": 0} + stats["by_status"][status]["count"] += 1 + stats["by_status"][status]["amount"] += float(payment.amount) + # Group by month for payment in payments: - month_key = payment.payment_date.strftime('%Y-%m') - if month_key not in stats['by_month']: - stats['by_month'][month_key] = {'count': 0, 'amount': 0} - stats['by_month'][month_key]['count'] += 1 - stats['by_month'][month_key]['amount'] += float(payment.amount) - + month_key = payment.payment_date.strftime("%Y-%m") + if month_key not in stats["by_month"]: + stats["by_month"][month_key] = {"count": 0, "amount": 0} + stats["by_month"][month_key]["count"] += 1 + stats["by_month"][month_key]["amount"] += float(payment.amount) + return jsonify(stats) -@payments_bp.route('/payments/export/excel') +@payments_bp.route("/payments/export/excel") @login_required def export_payments_excel(): """Export payments list as Excel file""" # Get filter parameters - status_filter = request.args.get('status', '') - method_filter = request.args.get('method', '') - date_from = request.args.get('date_from', '') - date_to = request.args.get('date_to', '') - invoice_id = request.args.get('invoice_id', type=int) - + status_filter = request.args.get("status", "") + method_filter = request.args.get("method", "") + date_from = request.args.get("date_from", "") + date_to = request.args.get("date_to", "") + invoice_id = request.args.get("invoice_id", type=int) + # Base query query = Payment.query - + # Apply filters based on user role if not current_user.is_admin: # Regular users can only see payments for their own invoices query = query.join(Invoice).filter(Invoice.created_by == current_user.id) - + # Apply additional filters if status_filter: query = query.filter(Payment.status == status_filter) - + if method_filter: query = query.filter(Payment.method == method_filter) - + if date_from: try: - date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date() + date_from_obj = datetime.strptime(date_from, "%Y-%m-%d").date() query = query.filter(Payment.payment_date >= date_from_obj) except ValueError: pass - + if date_to: try: - date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date() + date_to_obj = datetime.strptime(date_to, "%Y-%m-%d").date() query = query.filter(Payment.payment_date <= date_to_obj) except ValueError: pass - + if invoice_id: query = query.filter(Payment.invoice_id == invoice_id) - + # Get payments payments = query.order_by(Payment.payment_date.desc()).all() - + # Create Excel file output, filename = create_payments_list_excel(payments) - + # Track Excel export event - log_event("export.excel", - user_id=current_user.id, - export_type="payments_list", - num_rows=len(payments)) - track_event(current_user.id, "export.excel", { - "export_type": "payments_list", - "num_rows": len(payments) - }) - + log_event("export.excel", user_id=current_user.id, export_type="payments_list", num_rows=len(payments)) + track_event(current_user.id, "export.excel", {"export_type": "payments_list", "num_rows": len(payments)}) + return send_file( output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", as_attachment=True, - download_name=filename + download_name=filename, ) def get_user_invoices(): """Get invoices accessible by current user""" if current_user.is_admin: - return Invoice.query.filter(Invoice.status != 'cancelled').order_by(Invoice.invoice_number.desc()).all() + return Invoice.query.filter(Invoice.status != "cancelled").order_by(Invoice.invoice_number.desc()).all() else: - return Invoice.query.filter( - Invoice.created_by == current_user.id, - Invoice.status != 'cancelled' - ).order_by(Invoice.invoice_number.desc()).all() - + return ( + Invoice.query.filter(Invoice.created_by == current_user.id, Invoice.status != "cancelled") + .order_by(Invoice.invoice_number.desc()) + .all() + ) diff --git a/app/routes/per_diem.py b/app/routes/per_diem.py index b8600484..f47b2dfb 100644 --- a/app/routes/per_diem.py +++ b/app/routes/per_diem.py @@ -8,85 +8,79 @@ from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required -per_diem_bp = Blueprint('per_diem', __name__) +per_diem_bp = Blueprint("per_diem", __name__) -@per_diem_bp.route('/per-diem') +@per_diem_bp.route("/per-diem") @login_required def list_per_diem(): """List all per diem claims with filters""" from app import track_page_view + track_page_view("per_diem_list") - - page = request.args.get('page', 1, type=int) - per_page = request.args.get('per_page', 25, type=int) - + + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 25, type=int) + # Filter parameters - status = request.args.get('status', '').strip() - project_id = request.args.get('project_id', type=int) - client_id = request.args.get('client_id', type=int) - start_date = request.args.get('start_date', '').strip() - end_date = request.args.get('end_date', '').strip() - + status = request.args.get("status", "").strip() + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + start_date = request.args.get("start_date", "").strip() + end_date = request.args.get("end_date", "").strip() + # Build query query = PerDiem.query - + # Non-admin users can only see their own claims if not current_user.is_admin: - query = query.filter( - db.or_( - PerDiem.user_id == current_user.id, - PerDiem.approved_by == current_user.id - ) - ) - + query = query.filter(db.or_(PerDiem.user_id == current_user.id, PerDiem.approved_by == current_user.id)) + # Apply filters if status: query = query.filter(PerDiem.status == status) - + if project_id: query = query.filter(PerDiem.project_id == project_id) - + if client_id: query = query.filter(PerDiem.client_id == client_id) - + if start_date: try: - start = datetime.strptime(start_date, '%Y-%m-%d').date() + start = datetime.strptime(start_date, "%Y-%m-%d").date() query = query.filter(PerDiem.start_date >= start) except ValueError: pass - + if end_date: try: - end = datetime.strptime(end_date, '%Y-%m-%d').date() + end = datetime.strptime(end_date, "%Y-%m-%d").date() query = query.filter(PerDiem.end_date <= end) except ValueError: pass - + # Paginate per_diem_pagination = query.order_by(PerDiem.start_date.desc()).paginate( - page=page, - per_page=per_page, - error_out=False + page=page, per_page=per_page, error_out=False ) - + # Get filter options - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() - + # Calculate totals - total_amount_query = db.session.query( - db.func.sum(PerDiem.calculated_amount) - ).filter(PerDiem.status.in_(['approved', 'reimbursed'])) - + total_amount_query = db.session.query(db.func.sum(PerDiem.calculated_amount)).filter( + PerDiem.status.in_(["approved", "reimbursed"]) + ) + if not current_user.is_admin: total_amount_query = total_amount_query.filter(PerDiem.user_id == current_user.id) - + total_amount = total_amount_query.scalar() or 0 - + return render_template( - 'per_diem/list.html', + "per_diem/list.html", per_diem_claims=per_diem_pagination.items, pagination=per_diem_pagination, projects=projects, @@ -96,91 +90,86 @@ def list_per_diem(): project_id=project_id, client_id=client_id, start_date=start_date, - end_date=end_date + end_date=end_date, ) -@per_diem_bp.route('/per-diem/create', methods=['GET', 'POST']) +@per_diem_bp.route("/per-diem/create", methods=["GET", "POST"]) @login_required def create_per_diem(): """Create a new per diem claim""" - if request.method == 'GET': - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + if request.method == "GET": + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() - - return render_template( - 'per_diem/form.html', - per_diem=None, - projects=projects, - clients=clients - ) - + + return render_template("per_diem/form.html", per_diem=None, projects=projects, clients=clients) + try: # Get form data - trip_purpose = request.form.get('trip_purpose', '').strip() - start_date_str = request.form.get('start_date', '').strip() - end_date_str = request.form.get('end_date', '').strip() - country = request.form.get('country', '').strip() - city = request.form.get('city', '').strip() - + trip_purpose = request.form.get("trip_purpose", "").strip() + start_date_str = request.form.get("start_date", "").strip() + end_date_str = request.form.get("end_date", "").strip() + country = request.form.get("country", "").strip() + city = request.form.get("city", "").strip() + # Validate required fields if not all([trip_purpose, start_date_str, end_date_str, country]): - flash(_('Please fill in all required fields'), 'error') - return redirect(url_for('per_diem.create_per_diem')) - + flash(_("Please fill in all required fields"), "error") + return redirect(url_for("per_diem.create_per_diem")) + # Parse dates try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('per_diem.create_per_diem')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("per_diem.create_per_diem")) + if start_date > end_date: - flash(_('Start date must be before end date'), 'error') - return redirect(url_for('per_diem.create_per_diem')) - + flash(_("Start date must be before end date"), "error") + return redirect(url_for("per_diem.create_per_diem")) + # Parse times if provided departure_time = None return_time = None - departure_time_str = request.form.get('departure_time', '').strip() - return_time_str = request.form.get('return_time', '').strip() - + departure_time_str = request.form.get("departure_time", "").strip() + return_time_str = request.form.get("return_time", "").strip() + if departure_time_str: try: - departure_time = datetime.strptime(departure_time_str, '%H:%M').time() + departure_time = datetime.strptime(departure_time_str, "%H:%M").time() except ValueError: pass - + if return_time_str: try: - return_time = datetime.strptime(return_time_str, '%H:%M').time() + return_time = datetime.strptime(return_time_str, "%H:%M").time() except ValueError: pass - + # Get or calculate full/half days - auto_calculate = request.form.get('auto_calculate_days') == 'on' - + auto_calculate = request.form.get("auto_calculate_days") == "on" + if auto_calculate: days_calc = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time) - full_days = days_calc['full_days'] - half_days = days_calc['half_days'] + full_days = days_calc["full_days"] + half_days = days_calc["half_days"] else: - full_days = int(request.form.get('full_days', 0)) - half_days = int(request.form.get('half_days', 0)) - + full_days = int(request.form.get("full_days", 0)) + half_days = int(request.form.get("half_days", 0)) + # Get applicable rate rate = PerDiemRate.get_rate_for_location(country, city, start_date) - + if not rate: - flash(_('No per diem rate found for this location. Please configure rates first.'), 'error') - return redirect(url_for('per_diem.create_per_diem')) - + flash(_("No per diem rate found for this location. Please configure rates first."), "error") + return redirect(url_for("per_diem.create_per_diem")) + # Meal deductions - breakfast_provided = int(request.form.get('breakfast_provided', 0)) - lunch_provided = int(request.form.get('lunch_provided', 0)) - dinner_provided = int(request.form.get('dinner_provided', 0)) - + breakfast_provided = int(request.form.get("breakfast_provided", 0)) + lunch_provided = int(request.form.get("lunch_provided", 0)) + dinner_provided = int(request.form.get("dinner_provided", 0)) + # Create per diem claim per_diem = PerDiem( user_id=current_user.id, @@ -191,9 +180,9 @@ def create_per_diem(): city=city, full_day_rate=rate.full_day_rate, half_day_rate=rate.half_day_rate, - description=request.form.get('description'), - project_id=request.form.get('project_id', type=int), - client_id=request.form.get('client_id', type=int), + description=request.form.get("description"), + project_id=request.form.get("project_id", type=int), + client_id=request.form.get("client_id", type=int), per_diem_rate_id=rate.id, departure_time=departure_time, return_time=return_time, @@ -202,527 +191,553 @@ def create_per_diem(): breakfast_provided=breakfast_provided, lunch_provided=lunch_provided, dinner_provided=dinner_provided, - breakfast_deduction=rate.breakfast_rate or Decimal('0'), - lunch_deduction=rate.lunch_rate or Decimal('0'), - dinner_deduction=rate.dinner_rate or Decimal('0'), + breakfast_deduction=rate.breakfast_rate or Decimal("0"), + lunch_deduction=rate.lunch_rate or Decimal("0"), + dinner_deduction=rate.dinner_rate or Decimal("0"), currency_code=rate.currency_code, - notes=request.form.get('notes') + notes=request.form.get("notes"), ) - + db.session.add(per_diem) - + # Create expense if requested - if request.form.get('create_expense') == 'on': + if request.form.get("create_expense") == "on": expense = per_diem.create_expense() if expense: db.session.add(expense) - + if safe_commit(db): - flash(_('Per diem claim created successfully'), 'success') - log_event('per_diem_created', user_id=current_user.id, per_diem_id=per_diem.id) - track_event(current_user.id, 'per_diem.created', { - 'per_diem_id': per_diem.id, - 'amount': float(per_diem.calculated_amount), - 'days': per_diem.total_days - }) - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id)) + flash(_("Per diem claim created successfully"), "success") + log_event("per_diem_created", user_id=current_user.id, per_diem_id=per_diem.id) + track_event( + current_user.id, + "per_diem.created", + {"per_diem_id": per_diem.id, "amount": float(per_diem.calculated_amount), "days": per_diem.total_days}, + ) + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem.id)) else: - flash(_('Error creating per diem claim'), 'error') - return redirect(url_for('per_diem.create_per_diem')) - + flash(_("Error creating per diem claim"), "error") + return redirect(url_for("per_diem.create_per_diem")) + except Exception as e: current_app.logger.error(f"Error creating per diem claim: {e}") - flash(_('Error creating per diem claim'), 'error') - return redirect(url_for('per_diem.create_per_diem')) + flash(_("Error creating per diem claim"), "error") + return redirect(url_for("per_diem.create_per_diem")) -@per_diem_bp.route('/per-diem/') +@per_diem_bp.route("/per-diem/") @login_required def view_per_diem(per_diem_id): """View per diem claim details""" per_diem = PerDiem.query.get_or_404(per_diem_id) - + # Check permission if not current_user.is_admin and per_diem.user_id != current_user.id and per_diem.approved_by != current_user.id: - flash(_('You do not have permission to view this per diem claim'), 'error') - return redirect(url_for('per_diem.list_per_diem')) - + flash(_("You do not have permission to view this per diem claim"), "error") + return redirect(url_for("per_diem.list_per_diem")) + from app import track_page_view - track_page_view("per_diem_detail", properties={'per_diem_id': per_diem_id}) - - return render_template('per_diem/view.html', per_diem=per_diem) + track_page_view("per_diem_detail", properties={"per_diem_id": per_diem_id}) -@per_diem_bp.route('/per-diem//edit', methods=['GET', 'POST']) + return render_template("per_diem/view.html", per_diem=per_diem) + + +@per_diem_bp.route("/per-diem//edit", methods=["GET", "POST"]) @login_required def edit_per_diem(per_diem_id): """Edit a per diem claim""" per_diem = PerDiem.query.get_or_404(per_diem_id) - + # Check permission if not current_user.is_admin and per_diem.user_id != current_user.id: - flash(_('You do not have permission to edit this per diem claim'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - + flash(_("You do not have permission to edit this per diem claim"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + # Cannot edit approved or reimbursed claims without admin privileges - if not current_user.is_admin and per_diem.status in ['approved', 'reimbursed']: - flash(_('Cannot edit approved or reimbursed per diem claims'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - - if request.method == 'GET': - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + if not current_user.is_admin and per_diem.status in ["approved", "reimbursed"]: + flash(_("Cannot edit approved or reimbursed per diem claims"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + + if request.method == "GET": + projects = Project.query.filter_by(status="active").order_by(Project.name).all() clients = Client.get_active_clients() - - return render_template( - 'per_diem/form.html', - per_diem=per_diem, - projects=projects, - clients=clients - ) - + + return render_template("per_diem/form.html", per_diem=per_diem, projects=projects, clients=clients) + try: # Update fields - per_diem.trip_purpose = request.form.get('trip_purpose', '').strip() - per_diem.description = request.form.get('description', '').strip() - per_diem.start_date = datetime.strptime(request.form.get('start_date'), '%Y-%m-%d').date() - per_diem.end_date = datetime.strptime(request.form.get('end_date'), '%Y-%m-%d').date() - per_diem.country = request.form.get('country', '').strip() - per_diem.city = request.form.get('city', '').strip() - per_diem.project_id = request.form.get('project_id', type=int) - per_diem.client_id = request.form.get('client_id', type=int) - per_diem.full_days = int(request.form.get('full_days', 0)) - per_diem.half_days = int(request.form.get('half_days', 0)) - per_diem.breakfast_provided = int(request.form.get('breakfast_provided', 0)) - per_diem.lunch_provided = int(request.form.get('lunch_provided', 0)) - per_diem.dinner_provided = int(request.form.get('dinner_provided', 0)) - per_diem.notes = request.form.get('notes') + per_diem.trip_purpose = request.form.get("trip_purpose", "").strip() + per_diem.description = request.form.get("description", "").strip() + per_diem.start_date = datetime.strptime(request.form.get("start_date"), "%Y-%m-%d").date() + per_diem.end_date = datetime.strptime(request.form.get("end_date"), "%Y-%m-%d").date() + per_diem.country = request.form.get("country", "").strip() + per_diem.city = request.form.get("city", "").strip() + per_diem.project_id = request.form.get("project_id", type=int) + per_diem.client_id = request.form.get("client_id", type=int) + per_diem.full_days = int(request.form.get("full_days", 0)) + per_diem.half_days = int(request.form.get("half_days", 0)) + per_diem.breakfast_provided = int(request.form.get("breakfast_provided", 0)) + per_diem.lunch_provided = int(request.form.get("lunch_provided", 0)) + per_diem.dinner_provided = int(request.form.get("dinner_provided", 0)) + per_diem.notes = request.form.get("notes") per_diem.updated_at = datetime.utcnow() - + # Recalculate amount per_diem.recalculate_amount() - + if safe_commit(db): - flash(_('Per diem claim updated successfully'), 'success') - log_event('per_diem_updated', user_id=current_user.id, per_diem_id=per_diem.id) - track_event(current_user.id, 'per_diem.updated', {'per_diem_id': per_diem.id}) - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id)) + flash(_("Per diem claim updated successfully"), "success") + log_event("per_diem_updated", user_id=current_user.id, per_diem_id=per_diem.id) + track_event(current_user.id, "per_diem.updated", {"per_diem_id": per_diem.id}) + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem.id)) else: - flash(_('Error updating per diem claim'), 'error') - return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id)) - + flash(_("Error updating per diem claim"), "error") + return redirect(url_for("per_diem.edit_per_diem", per_diem_id=per_diem_id)) + except Exception as e: current_app.logger.error(f"Error updating per diem claim: {e}") - flash(_('Error updating per diem claim'), 'error') - return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id)) + flash(_("Error updating per diem claim"), "error") + return redirect(url_for("per_diem.edit_per_diem", per_diem_id=per_diem_id)) -@per_diem_bp.route('/per-diem//delete', methods=['POST']) +@per_diem_bp.route("/per-diem//delete", methods=["POST"]) @login_required def delete_per_diem(per_diem_id): """Delete a per diem claim""" per_diem = PerDiem.query.get_or_404(per_diem_id) - + # Check permission if not current_user.is_admin and per_diem.user_id != current_user.id: - flash(_('You do not have permission to delete this per diem claim'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - + flash(_("You do not have permission to delete this per diem claim"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + try: db.session.delete(per_diem) - + if safe_commit(db): - flash(_('Per diem claim deleted successfully'), 'success') - log_event('per_diem_deleted', user_id=current_user.id, per_diem_id=per_diem_id) - track_event(current_user.id, 'per_diem.deleted', {'per_diem_id': per_diem_id}) + flash(_("Per diem claim deleted successfully"), "success") + log_event("per_diem_deleted", user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, "per_diem.deleted", {"per_diem_id": per_diem_id}) else: - flash(_('Error deleting per diem claim'), 'error') - + flash(_("Error deleting per diem claim"), "error") + except Exception as e: current_app.logger.error(f"Error deleting per diem claim: {e}") - flash(_('Error deleting per diem claim'), 'error') - - return redirect(url_for('per_diem.list_per_diem')) + flash(_("Error deleting per diem claim"), "error") + + return redirect(url_for("per_diem.list_per_diem")) -@per_diem_bp.route('/per-diem/bulk-delete', methods=['POST']) + +@per_diem_bp.route("/per-diem/bulk-delete", methods=["POST"]) @login_required def bulk_delete_per_diem(): """Delete multiple per diem claims at once""" - per_diem_ids = request.form.getlist('per_diem_ids[]') - + per_diem_ids = request.form.getlist("per_diem_ids[]") + if not per_diem_ids: - flash(_('No per diem claims selected for deletion'), 'warning') - return redirect(url_for('per_diem.list_per_diem')) - + flash(_("No per diem claims selected for deletion"), "warning") + return redirect(url_for("per_diem.list_per_diem")) + deleted_count = 0 skipped_count = 0 errors = [] - + for per_diem_id_str in per_diem_ids: try: per_diem_id = int(per_diem_id_str) per_diem = PerDiem.query.get(per_diem_id) - + if not per_diem: continue - + # Check permissions if not current_user.is_admin and per_diem.user_id != current_user.id: skipped_count += 1 errors.append(f"Per diem #{per_diem_id_str}: No permission") continue - + db.session.delete(per_diem) deleted_count += 1 - + except Exception as e: skipped_count += 1 errors.append(f"ID {per_diem_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: if not safe_commit(db): - flash(_('Could not delete per diem claims due to a database error. Please check server logs.'), 'error') - return redirect(url_for('per_diem.list_per_diem')) - - log_event('per_diem_bulk_deleted', user_id=current_user.id, count=deleted_count) - track_event(current_user.id, 'per_diem.bulk_deleted', {'count': deleted_count}) - + flash(_("Could not delete per diem claims due to a database error. Please check server logs."), "error") + return redirect(url_for("per_diem.list_per_diem")) + + log_event("per_diem_bulk_deleted", user_id=current_user.id, count=deleted_count) + track_event(current_user.id, "per_diem.bulk_deleted", {"count": deleted_count}) + # Show appropriate messages if deleted_count > 0: - flash(_('Successfully deleted %(count)d per diem claim(s)', count=deleted_count), 'success') - + flash(_("Successfully deleted %(count)d per diem claim(s)", count=deleted_count), "success") + if skipped_count > 0: - flash(_('Skipped %(count)d per diem claim(s): %(errors)s', count=skipped_count, errors="; ".join(errors[:3])), 'warning') - - return redirect(url_for('per_diem.list_per_diem')) + flash( + _("Skipped %(count)d per diem claim(s): %(errors)s", count=skipped_count, errors="; ".join(errors[:3])), + "warning", + ) + + return redirect(url_for("per_diem.list_per_diem")) -@per_diem_bp.route('/per-diem/bulk-status', methods=['POST']) + +@per_diem_bp.route("/per-diem/bulk-status", methods=["POST"]) @login_required def bulk_update_status(): """Update status for multiple per diem claims at once""" - per_diem_ids = request.form.getlist('per_diem_ids[]') - new_status = request.form.get('status', '').strip() - + per_diem_ids = request.form.getlist("per_diem_ids[]") + new_status = request.form.get("status", "").strip() + if not per_diem_ids: - flash(_('No per diem claims selected'), 'warning') - return redirect(url_for('per_diem.list_per_diem')) - + flash(_("No per diem claims selected"), "warning") + return redirect(url_for("per_diem.list_per_diem")) + # Validate status - valid_statuses = ['pending', 'approved', 'rejected', 'reimbursed'] + valid_statuses = ["pending", "approved", "rejected", "reimbursed"] if not new_status or new_status not in valid_statuses: - flash(_('Invalid status value'), 'error') - return redirect(url_for('per_diem.list_per_diem')) - + flash(_("Invalid status value"), "error") + return redirect(url_for("per_diem.list_per_diem")) + updated_count = 0 skipped_count = 0 - + for per_diem_id_str in per_diem_ids: try: per_diem_id = int(per_diem_id_str) per_diem = PerDiem.query.get(per_diem_id) - + if not per_diem: continue - + # Check permissions - non-admin users can only update their own claims if not current_user.is_admin and per_diem.user_id != current_user.id: skipped_count += 1 continue - + per_diem.status = new_status updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: if not safe_commit(db): - flash(_('Could not update per diem claims due to a database error'), 'error') - return redirect(url_for('per_diem.list_per_diem')) - - flash(_('Successfully updated %(count)d per diem claim(s) to %(status)s', count=updated_count, status=new_status), 'success') - + flash(_("Could not update per diem claims due to a database error"), "error") + return redirect(url_for("per_diem.list_per_diem")) + + flash( + _("Successfully updated %(count)d per diem claim(s) to %(status)s", count=updated_count, status=new_status), + "success", + ) + if skipped_count > 0: - flash(_('Skipped %(count)d per diem claim(s) (no permission)', count=skipped_count), 'warning') - - return redirect(url_for('per_diem.list_per_diem')) + flash(_("Skipped %(count)d per diem claim(s) (no permission)", count=skipped_count), "warning") + + return redirect(url_for("per_diem.list_per_diem")) -@per_diem_bp.route('/per-diem//approve', methods=['POST']) +@per_diem_bp.route("/per-diem//approve", methods=["POST"]) @login_required def approve_per_diem(per_diem_id): """Approve a per diem claim""" if not current_user.is_admin: - flash(_('Only administrators can approve per diem claims'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - + flash(_("Only administrators can approve per diem claims"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + per_diem = PerDiem.query.get_or_404(per_diem_id) - - if per_diem.status != 'pending': - flash(_('Only pending per diem claims can be approved'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - + + if per_diem.status != "pending": + flash(_("Only pending per diem claims can be approved"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + try: - notes = request.form.get('approval_notes', '').strip() + notes = request.form.get("approval_notes", "").strip() per_diem.approve(current_user.id, notes) - + if safe_commit(db): - flash(_('Per diem claim approved successfully'), 'success') - log_event('per_diem_approved', user_id=current_user.id, per_diem_id=per_diem_id) - track_event(current_user.id, 'per_diem.approved', {'per_diem_id': per_diem_id}) + flash(_("Per diem claim approved successfully"), "success") + log_event("per_diem_approved", user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, "per_diem.approved", {"per_diem_id": per_diem_id}) else: - flash(_('Error approving per diem claim'), 'error') - + flash(_("Error approving per diem claim"), "error") + except Exception as e: current_app.logger.error(f"Error approving per diem claim: {e}") - flash(_('Error approving per diem claim'), 'error') - - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + flash(_("Error approving per diem claim"), "error") + + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) -@per_diem_bp.route('/per-diem//reject', methods=['POST']) +@per_diem_bp.route("/per-diem//reject", methods=["POST"]) @login_required def reject_per_diem(per_diem_id): """Reject a per diem claim""" if not current_user.is_admin: - flash(_('Only administrators can reject per diem claims'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - + flash(_("Only administrators can reject per diem claims"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + per_diem = PerDiem.query.get_or_404(per_diem_id) - - if per_diem.status != 'pending': - flash(_('Only pending per diem claims can be rejected'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - + + if per_diem.status != "pending": + flash(_("Only pending per diem claims can be rejected"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + try: - reason = request.form.get('rejection_reason', '').strip() + reason = request.form.get("rejection_reason", "").strip() if not reason: - flash(_('Rejection reason is required'), 'error') - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) - + flash(_("Rejection reason is required"), "error") + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) + per_diem.reject(current_user.id, reason) - + if safe_commit(db): - flash(_('Per diem claim rejected'), 'success') - log_event('per_diem_rejected', user_id=current_user.id, per_diem_id=per_diem_id) - track_event(current_user.id, 'per_diem.rejected', {'per_diem_id': per_diem_id}) + flash(_("Per diem claim rejected"), "success") + log_event("per_diem_rejected", user_id=current_user.id, per_diem_id=per_diem_id) + track_event(current_user.id, "per_diem.rejected", {"per_diem_id": per_diem_id}) else: - flash(_('Error rejecting per diem claim'), 'error') - + flash(_("Error rejecting per diem claim"), "error") + except Exception as e: current_app.logger.error(f"Error rejecting per diem claim: {e}") - flash(_('Error rejecting per diem claim'), 'error') - - return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id)) + flash(_("Error rejecting per diem claim"), "error") + + return redirect(url_for("per_diem.view_per_diem", per_diem_id=per_diem_id)) # Per Diem Rates Management -@per_diem_bp.route('/per-diem/rates') +@per_diem_bp.route("/per-diem/rates") @login_required -@admin_or_permission_required('per_diem_rates.view') +@admin_or_permission_required("per_diem_rates.view") def list_rates(): """List all per diem rates""" from app import track_page_view + track_page_view("per_diem_rates_list") - - rates = PerDiemRate.query.filter_by(is_active=True).order_by( - PerDiemRate.country, PerDiemRate.city, PerDiemRate.effective_from.desc() - ).all() - - return render_template('per_diem/rates_list.html', rates=rates) + + rates = ( + PerDiemRate.query.filter_by(is_active=True) + .order_by(PerDiemRate.country, PerDiemRate.city, PerDiemRate.effective_from.desc()) + .all() + ) + + return render_template("per_diem/rates_list.html", rates=rates) -@per_diem_bp.route('/per-diem/rates/create', methods=['GET', 'POST']) +@per_diem_bp.route("/per-diem/rates/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('per_diem_rates.create') +@admin_or_permission_required("per_diem_rates.create") def create_rate(): """Create a new per diem rate""" - if request.method == 'GET': - return render_template('per_diem/rate_form.html', rate=None) - + if request.method == "GET": + return render_template("per_diem/rate_form.html", rate=None) + try: - country = request.form.get('country', '').strip() - full_day_rate = request.form.get('full_day_rate', '').strip() - half_day_rate = request.form.get('half_day_rate', '').strip() - effective_from = request.form.get('effective_from', '').strip() - + country = request.form.get("country", "").strip() + full_day_rate = request.form.get("full_day_rate", "").strip() + half_day_rate = request.form.get("half_day_rate", "").strip() + effective_from = request.form.get("effective_from", "").strip() + if not all([country, full_day_rate, half_day_rate, effective_from]): - flash(_('Please fill in all required fields'), 'error') - return redirect(url_for('per_diem.create_rate')) - + flash(_("Please fill in all required fields"), "error") + return redirect(url_for("per_diem.create_rate")) + rate = PerDiemRate( country=country, - city=request.form.get('city'), + city=request.form.get("city"), full_day_rate=Decimal(full_day_rate), half_day_rate=Decimal(half_day_rate), - breakfast_rate=request.form.get('breakfast_rate') or None, - lunch_rate=request.form.get('lunch_rate') or None, - dinner_rate=request.form.get('dinner_rate') or None, - incidental_rate=request.form.get('incidental_rate') or None, - currency_code=request.form.get('currency_code', 'EUR'), - effective_from=datetime.strptime(effective_from, '%Y-%m-%d').date(), - effective_to=datetime.strptime(request.form.get('effective_to'), '%Y-%m-%d').date() if request.form.get('effective_to') else None, - notes=request.form.get('notes') + breakfast_rate=request.form.get("breakfast_rate") or None, + lunch_rate=request.form.get("lunch_rate") or None, + dinner_rate=request.form.get("dinner_rate") or None, + incidental_rate=request.form.get("incidental_rate") or None, + currency_code=request.form.get("currency_code", "EUR"), + effective_from=datetime.strptime(effective_from, "%Y-%m-%d").date(), + effective_to=( + datetime.strptime(request.form.get("effective_to"), "%Y-%m-%d").date() + if request.form.get("effective_to") + else None + ), + notes=request.form.get("notes"), ) - + db.session.add(rate) - + if safe_commit(db): - flash(_('Per diem rate created successfully'), 'success') - log_event('per_diem_rate_created', user_id=current_user.id, rate_id=rate.id) - return redirect(url_for('per_diem.list_rates')) + flash(_("Per diem rate created successfully"), "success") + log_event("per_diem_rate_created", user_id=current_user.id, rate_id=rate.id) + return redirect(url_for("per_diem.list_rates")) else: - flash(_('Error creating per diem rate'), 'error') - return redirect(url_for('per_diem.create_rate')) - + flash(_("Error creating per diem rate"), "error") + return redirect(url_for("per_diem.create_rate")) + except Exception as e: current_app.logger.error(f"Error creating per diem rate: {e}") - flash(_('Error creating per diem rate'), 'error') - return redirect(url_for('per_diem.create_rate')) + flash(_("Error creating per diem rate"), "error") + return redirect(url_for("per_diem.create_rate")) -@per_diem_bp.route('/per-diem/rates//edit', methods=['GET', 'POST']) +@per_diem_bp.route("/per-diem/rates//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('per_diem_rates.edit') +@admin_or_permission_required("per_diem_rates.edit") def edit_rate(rate_id): """Edit an existing per diem rate""" rate = PerDiemRate.query.get_or_404(rate_id) - - if request.method == 'GET': - return render_template('per_diem/rate_form.html', rate=rate) - + + if request.method == "GET": + return render_template("per_diem/rate_form.html", rate=rate) + try: - country = request.form.get('country', '').strip() - full_day_rate = request.form.get('full_day_rate', '').strip() - half_day_rate = request.form.get('half_day_rate', '').strip() - effective_from = request.form.get('effective_from', '').strip() - + country = request.form.get("country", "").strip() + full_day_rate = request.form.get("full_day_rate", "").strip() + half_day_rate = request.form.get("half_day_rate", "").strip() + effective_from = request.form.get("effective_from", "").strip() + if not all([country, full_day_rate, half_day_rate, effective_from]): - flash(_('Please fill in all required fields'), 'error') - return redirect(url_for('per_diem.edit_rate', rate_id=rate_id)) - + flash(_("Please fill in all required fields"), "error") + return redirect(url_for("per_diem.edit_rate", rate_id=rate_id)) + # Update rate fields rate.country = country - rate.city = request.form.get('city') or None + rate.city = request.form.get("city") or None rate.full_day_rate = Decimal(full_day_rate) rate.half_day_rate = Decimal(half_day_rate) - rate.breakfast_rate = Decimal(request.form.get('breakfast_rate')) if request.form.get('breakfast_rate') else None - rate.lunch_rate = Decimal(request.form.get('lunch_rate')) if request.form.get('lunch_rate') else None - rate.dinner_rate = Decimal(request.form.get('dinner_rate')) if request.form.get('dinner_rate') else None - rate.incidental_rate = Decimal(request.form.get('incidental_rate')) if request.form.get('incidental_rate') else None - rate.currency_code = request.form.get('currency_code', 'EUR') - rate.effective_from = datetime.strptime(effective_from, '%Y-%m-%d').date() - rate.effective_to = datetime.strptime(request.form.get('effective_to'), '%Y-%m-%d').date() if request.form.get('effective_to') else None - rate.notes = request.form.get('notes') + rate.breakfast_rate = ( + Decimal(request.form.get("breakfast_rate")) if request.form.get("breakfast_rate") else None + ) + rate.lunch_rate = Decimal(request.form.get("lunch_rate")) if request.form.get("lunch_rate") else None + rate.dinner_rate = Decimal(request.form.get("dinner_rate")) if request.form.get("dinner_rate") else None + rate.incidental_rate = ( + Decimal(request.form.get("incidental_rate")) if request.form.get("incidental_rate") else None + ) + rate.currency_code = request.form.get("currency_code", "EUR") + rate.effective_from = datetime.strptime(effective_from, "%Y-%m-%d").date() + rate.effective_to = ( + datetime.strptime(request.form.get("effective_to"), "%Y-%m-%d").date() + if request.form.get("effective_to") + else None + ) + rate.notes = request.form.get("notes") # updated_at is automatically updated by the model's onupdate - - if safe_commit('edit_per_diem_rate', {'rate_id': rate.id}): - flash(_('Per diem rate updated successfully'), 'success') - log_event('per_diem_rate_updated', user_id=current_user.id, rate_id=rate.id) - track_event(current_user.id, 'per_diem_rate.updated', {'rate_id': rate.id}) - return redirect(url_for('per_diem.list_rates')) + + if safe_commit("edit_per_diem_rate", {"rate_id": rate.id}): + flash(_("Per diem rate updated successfully"), "success") + log_event("per_diem_rate_updated", user_id=current_user.id, rate_id=rate.id) + track_event(current_user.id, "per_diem_rate.updated", {"rate_id": rate.id}) + return redirect(url_for("per_diem.list_rates")) else: - flash(_('Error updating per diem rate'), 'error') - return redirect(url_for('per_diem.edit_rate', rate_id=rate_id)) - + flash(_("Error updating per diem rate"), "error") + return redirect(url_for("per_diem.edit_rate", rate_id=rate_id)) + except Exception as e: current_app.logger.error(f"Error updating per diem rate: {e}") - flash(_('Error updating per diem rate'), 'error') - return redirect(url_for('per_diem.edit_rate', rate_id=rate_id)) + flash(_("Error updating per diem rate"), "error") + return redirect(url_for("per_diem.edit_rate", rate_id=rate_id)) -@per_diem_bp.route('/per-diem/rates//delete', methods=['POST']) +@per_diem_bp.route("/per-diem/rates//delete", methods=["POST"]) @login_required -@admin_or_permission_required('per_diem_rates.delete') +@admin_or_permission_required("per_diem_rates.delete") def delete_rate(rate_id): """Delete a per diem rate""" rate = PerDiemRate.query.get_or_404(rate_id) - + try: # Check if rate is being used by any per diem claims from app.models import PerDiem + claims_using_rate = PerDiem.query.filter_by(per_diem_rate_id=rate_id).count() - + if claims_using_rate > 0: - flash(_('Cannot delete rate: It is being used by %(count)d per diem claim(s). Deactivate it instead.', count=claims_using_rate), 'error') - return redirect(url_for('per_diem.list_rates')) - + flash( + _( + "Cannot delete rate: It is being used by %(count)d per diem claim(s). Deactivate it instead.", + count=claims_using_rate, + ), + "error", + ) + return redirect(url_for("per_diem.list_rates")) + db.session.delete(rate) - - if safe_commit('delete_per_diem_rate', {'rate_id': rate_id}): - flash(_('Per diem rate deleted successfully'), 'success') - log_event('per_diem_rate_deleted', user_id=current_user.id, rate_id=rate_id) - track_event(current_user.id, 'per_diem_rate.deleted', {'rate_id': rate_id}) + + if safe_commit("delete_per_diem_rate", {"rate_id": rate_id}): + flash(_("Per diem rate deleted successfully"), "success") + log_event("per_diem_rate_deleted", user_id=current_user.id, rate_id=rate_id) + track_event(current_user.id, "per_diem_rate.deleted", {"rate_id": rate_id}) else: - flash(_('Error deleting per diem rate'), 'error') - + flash(_("Error deleting per diem rate"), "error") + except Exception as e: current_app.logger.error(f"Error deleting per diem rate: {e}") - flash(_('Error deleting per diem rate'), 'error') - - return redirect(url_for('per_diem.list_rates')) + flash(_("Error deleting per diem rate"), "error") + + return redirect(url_for("per_diem.list_rates")) # API endpoints -@per_diem_bp.route('/api/per-diem', methods=['GET']) +@per_diem_bp.route("/api/per-diem", methods=["GET"]) @login_required def api_list_per_diem(): """API endpoint to list per diem claims""" - status = request.args.get('status', '').strip() - + status = request.args.get("status", "").strip() + query = PerDiem.query - + if not current_user.is_admin: query = query.filter_by(user_id=current_user.id) - + if status: query = query.filter(PerDiem.status == status) - + claims = query.order_by(PerDiem.start_date.desc()).all() - - return jsonify({ - 'per_diem': [claim.to_dict() for claim in claims], - 'count': len(claims) - }) + return jsonify({"per_diem": [claim.to_dict() for claim in claims], "count": len(claims)}) -@per_diem_bp.route('/api/per-diem/rates/search', methods=['GET']) + +@per_diem_bp.route("/api/per-diem/rates/search", methods=["GET"]) @login_required def api_search_rates(): """API endpoint to search for per diem rates""" - country = request.args.get('country', '').strip() - city = request.args.get('city', '').strip() - date_str = request.args.get('date', '').strip() - + country = request.args.get("country", "").strip() + city = request.args.get("city", "").strip() + date_str = request.args.get("date", "").strip() + if not country: - return jsonify({'error': 'Country is required'}), 400 - - search_date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else date.today() - + return jsonify({"error": "Country is required"}), 400 + + search_date = datetime.strptime(date_str, "%Y-%m-%d").date() if date_str else date.today() + rate = PerDiemRate.get_rate_for_location(country, city, search_date) - + if rate: return jsonify(rate.to_dict()) else: - return jsonify({'error': 'No rate found for this location'}), 404 + return jsonify({"error": "No rate found for this location"}), 404 -@per_diem_bp.route('/api/per-diem/calculate-days', methods=['POST']) +@per_diem_bp.route("/api/per-diem/calculate-days", methods=["POST"]) @login_required def api_calculate_days(): """API endpoint to calculate full/half days from dates and times""" data = request.get_json() - + try: - start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date() - end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date() - departure_time = datetime.strptime(data.get('departure_time', ''), '%H:%M').time() if data.get('departure_time') else None - return_time = datetime.strptime(data.get('return_time', ''), '%H:%M').time() if data.get('return_time') else None - + start_date = datetime.strptime(data["start_date"], "%Y-%m-%d").date() + end_date = datetime.strptime(data["end_date"], "%Y-%m-%d").date() + departure_time = ( + datetime.strptime(data.get("departure_time", ""), "%H:%M").time() if data.get("departure_time") else None + ) + return_time = ( + datetime.strptime(data.get("return_time", ""), "%H:%M").time() if data.get("return_time") else None + ) + result = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time) - + return jsonify(result) - - except Exception as e: - return jsonify({'error': str(e)}), 400 + except Exception as e: + return jsonify({"error": str(e)}), 400 diff --git a/app/routes/permissions.py b/app/routes/permissions.py index 0f383c62..c956f629 100644 --- a/app/routes/permissions.py +++ b/app/routes/permissions.py @@ -1,4 +1,5 @@ """Routes for role and permission management (admin only)""" + from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user @@ -8,106 +9,106 @@ from app.utils.db import safe_commit from sqlalchemy.exc import IntegrityError -permissions_bp = Blueprint('permissions', __name__) +permissions_bp = Blueprint("permissions", __name__) -@permissions_bp.route('/admin/roles') +@permissions_bp.route("/admin/roles") @login_required @admin_required def list_roles(): """List all roles""" # Check if user has permission to view roles - if not current_user.is_admin and not current_user.has_permission('view_permissions'): - flash(_('You do not have permission to access this page'), 'error') - return redirect(url_for('main.dashboard')) - + if not current_user.is_admin and not current_user.has_permission("view_permissions"): + flash(_("You do not have permission to access this page"), "error") + return redirect(url_for("main.dashboard")) + roles = Role.query.order_by(Role.name).all() - return render_template('admin/roles/list.html', roles=roles) + return render_template("admin/roles/list.html", roles=roles) -@permissions_bp.route('/admin/roles/create', methods=['GET', 'POST']) +@permissions_bp.route("/admin/roles/create", methods=["GET", "POST"]) @login_required @admin_required def create_role(): """Create a new role""" # Check if user has permission to manage roles - if not current_user.is_admin and not current_user.has_permission('manage_roles'): - flash(_('You do not have permission to access this page'), 'error') - return redirect(url_for('main.dashboard')) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - + if not current_user.is_admin and not current_user.has_permission("manage_roles"): + flash(_("You do not have permission to access this page"), "error") + return redirect(url_for("main.dashboard")) + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + if not name: - flash(_('Role name is required'), 'error') - return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all()) - + flash(_("Role name is required"), "error") + return render_template("admin/roles/form.html", role=None, all_permissions=Permission.query.all()) + # Check if role already exists if Role.query.filter_by(name=name).first(): - flash(_('A role with this name already exists'), 'error') - return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all()) - + flash(_("A role with this name already exists"), "error") + return render_template("admin/roles/form.html", role=None, all_permissions=Permission.query.all()) + # Create role role = Role(name=name, description=description, is_system_role=False) db.session.add(role) - + # Assign selected permissions - permission_ids = request.form.getlist('permissions') + permission_ids = request.form.getlist("permissions") for perm_id in permission_ids: permission = Permission.query.get(int(perm_id)) if permission: role.add_permission(permission) - - if not safe_commit('create_role', {'name': name}): - flash(_('Could not create role due to a database error'), 'error') - return render_template('admin/roles/form.html', role=None, all_permissions=Permission.query.all()) - - flash(_('Role created successfully'), 'success') - return redirect(url_for('permissions.list_roles')) - + + if not safe_commit("create_role", {"name": name}): + flash(_("Could not create role due to a database error"), "error") + return render_template("admin/roles/form.html", role=None, all_permissions=Permission.query.all()) + + flash(_("Role created successfully"), "success") + return redirect(url_for("permissions.list_roles")) + # GET request all_permissions = Permission.query.order_by(Permission.category, Permission.name).all() - return render_template('admin/roles/form.html', role=None, all_permissions=all_permissions) + return render_template("admin/roles/form.html", role=None, all_permissions=all_permissions) -@permissions_bp.route('/admin/roles//edit', methods=['GET', 'POST']) +@permissions_bp.route("/admin/roles//edit", methods=["GET", "POST"]) @login_required @admin_required def edit_role(role_id): """Edit an existing role""" # Check if user has permission to manage roles - if not current_user.is_admin and not current_user.has_permission('manage_roles'): - flash(_('You do not have permission to access this page'), 'error') - return redirect(url_for('main.dashboard')) - + if not current_user.is_admin and not current_user.has_permission("manage_roles"): + flash(_("You do not have permission to access this page"), "error") + return redirect(url_for("main.dashboard")) + role = Role.query.get_or_404(role_id) - + # Prevent editing system roles if role.is_system_role: - flash(_('System roles cannot be edited'), 'warning') - return redirect(url_for('permissions.view_role', role_id=role.id)) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - + flash(_("System roles cannot be edited"), "warning") + return redirect(url_for("permissions.view_role", role_id=role.id)) + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + if not name: - flash(_('Role name is required'), 'error') - return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all()) - + flash(_("Role name is required"), "error") + return render_template("admin/roles/form.html", role=role, all_permissions=Permission.query.all()) + # Check if name is taken by another role existing = Role.query.filter_by(name=name).first() if existing and existing.id != role.id: - flash(_('A role with this name already exists'), 'error') - return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all()) - + flash(_("A role with this name already exists"), "error") + return render_template("admin/roles/form.html", role=role, all_permissions=Permission.query.all()) + # Update role role.name = name role.description = description - + # Update permissions - permission_ids = request.form.getlist('permissions') + permission_ids = request.form.getlist("permissions") # Remove all current permissions role.permissions = [] # Add selected permissions @@ -115,160 +116,166 @@ def edit_role(role_id): permission = Permission.query.get(int(perm_id)) if permission: role.add_permission(permission) - - if not safe_commit('edit_role', {'role_id': role.id}): - flash(_('Could not update role due to a database error'), 'error') - return render_template('admin/roles/form.html', role=role, all_permissions=Permission.query.all()) - - flash(_('Role updated successfully'), 'success') - return redirect(url_for('permissions.view_role', role_id=role.id)) - + + if not safe_commit("edit_role", {"role_id": role.id}): + flash(_("Could not update role due to a database error"), "error") + return render_template("admin/roles/form.html", role=role, all_permissions=Permission.query.all()) + + flash(_("Role updated successfully"), "success") + return redirect(url_for("permissions.view_role", role_id=role.id)) + # GET request all_permissions = Permission.query.order_by(Permission.category, Permission.name).all() - return render_template('admin/roles/form.html', role=role, all_permissions=all_permissions) + return render_template("admin/roles/form.html", role=role, all_permissions=all_permissions) -@permissions_bp.route('/admin/roles/') +@permissions_bp.route("/admin/roles/") @login_required @admin_required def view_role(role_id): """View role details""" # Check if user has permission to view roles - if not current_user.is_admin and not current_user.has_permission('view_permissions'): - flash(_('You do not have permission to access this page'), 'error') - return redirect(url_for('main.dashboard')) - + if not current_user.is_admin and not current_user.has_permission("view_permissions"): + flash(_("You do not have permission to access this page"), "error") + return redirect(url_for("main.dashboard")) + role = Role.query.get_or_404(role_id) users = role.users.all() - return render_template('admin/roles/view.html', role=role, users=users) + return render_template("admin/roles/view.html", role=role, users=users) -@permissions_bp.route('/admin/roles//delete', methods=['POST']) +@permissions_bp.route("/admin/roles//delete", methods=["POST"]) @login_required @admin_required @limiter.limit("10 per minute") def delete_role(role_id): """Delete a role""" # Check if user has permission to manage roles - if not current_user.is_admin and not current_user.has_permission('manage_roles'): - flash(_('You do not have permission to perform this action'), 'error') - return redirect(url_for('main.dashboard')) - + if not current_user.is_admin and not current_user.has_permission("manage_roles"): + flash(_("You do not have permission to perform this action"), "error") + return redirect(url_for("main.dashboard")) + role = Role.query.get_or_404(role_id) - + # Prevent deleting system roles if role.is_system_role: - flash(_('System roles cannot be deleted'), 'error') - return redirect(url_for('permissions.list_roles')) - + flash(_("System roles cannot be deleted"), "error") + return redirect(url_for("permissions.list_roles")) + # Check if role is assigned to any users if role.users.count() > 0: - flash(_('Cannot delete role that is assigned to users. Please reassign users first.'), 'error') - return redirect(url_for('permissions.view_role', role_id=role.id)) - + flash(_("Cannot delete role that is assigned to users. Please reassign users first."), "error") + return redirect(url_for("permissions.view_role", role_id=role.id)) + role_name = role.name db.session.delete(role) - - if not safe_commit('delete_role', {'role_id': role.id}): - flash(_('Could not delete role due to a database error'), 'error') - return redirect(url_for('permissions.list_roles')) - - flash(_('Role "%(name)s" deleted successfully', name=role_name), 'success') - return redirect(url_for('permissions.list_roles')) + if not safe_commit("delete_role", {"role_id": role.id}): + flash(_("Could not delete role due to a database error"), "error") + return redirect(url_for("permissions.list_roles")) -@permissions_bp.route('/admin/permissions') + flash(_('Role "%(name)s" deleted successfully', name=role_name), "success") + return redirect(url_for("permissions.list_roles")) + + +@permissions_bp.route("/admin/permissions") @login_required @admin_required def list_permissions(): """List all permissions""" # Check if user has permission to view permissions - if not current_user.is_admin and not current_user.has_permission('view_permissions'): - flash(_('You do not have permission to access this page'), 'error') - return redirect(url_for('main.dashboard')) - + if not current_user.is_admin and not current_user.has_permission("view_permissions"): + flash(_("You do not have permission to access this page"), "error") + return redirect(url_for("main.dashboard")) + # Group permissions by category permissions = Permission.query.order_by(Permission.category, Permission.name).all() - + # Organize by category permissions_by_category = {} for perm in permissions: - category = perm.category or 'general' + category = perm.category or "general" if category not in permissions_by_category: permissions_by_category[category] = [] permissions_by_category[category].append(perm) - - return render_template('admin/permissions/list.html', permissions_by_category=permissions_by_category) + + return render_template("admin/permissions/list.html", permissions_by_category=permissions_by_category) -@permissions_bp.route('/admin/users//roles', methods=['GET', 'POST']) +@permissions_bp.route("/admin/users//roles", methods=["GET", "POST"]) @login_required @admin_required def manage_user_roles(user_id): """Manage roles for a specific user""" # Check if user has permission to manage user roles - if not current_user.is_admin and not current_user.has_permission('manage_user_roles'): - flash(_('You do not have permission to access this page'), 'error') - return redirect(url_for('main.dashboard')) - + if not current_user.is_admin and not current_user.has_permission("manage_user_roles"): + flash(_("You do not have permission to access this page"), "error") + return redirect(url_for("main.dashboard")) + user = User.query.get_or_404(user_id) - - if request.method == 'POST': + + if request.method == "POST": # Get selected role IDs - role_ids = request.form.getlist('roles') - + role_ids = request.form.getlist("roles") + # Clear current roles user.roles = [] - + # Assign selected roles for role_id in role_ids: role = Role.query.get(int(role_id)) if role: user.add_role(role) - - if not safe_commit('manage_user_roles', {'user_id': user.id}): - flash(_('Could not update user roles due to a database error'), 'error') - return render_template('admin/users/roles.html', user=user, all_roles=Role.query.all()) - - flash(_('User roles updated successfully'), 'success') - return redirect(url_for('admin.edit_user', user_id=user.id)) - + + if not safe_commit("manage_user_roles", {"user_id": user.id}): + flash(_("Could not update user roles due to a database error"), "error") + return render_template("admin/users/roles.html", user=user, all_roles=Role.query.all()) + + flash(_("User roles updated successfully"), "success") + return redirect(url_for("admin.edit_user", user_id=user.id)) + # GET request all_roles = Role.query.order_by(Role.name).all() - return render_template('admin/users/roles.html', user=user, all_roles=all_roles) + return render_template("admin/users/roles.html", user=user, all_roles=all_roles) -@permissions_bp.route('/api/users//permissions') +@permissions_bp.route("/api/users//permissions") @login_required def get_user_permissions(user_id): """API endpoint to get user's effective permissions""" # Users can view their own permissions, admins can view any user's permissions if current_user.id != user_id and not current_user.is_admin: - return jsonify({'error': 'Unauthorized'}), 403 - + return jsonify({"error": "Unauthorized"}), 403 + user = User.query.get_or_404(user_id) permissions = user.get_all_permissions() - - return jsonify({ - 'user_id': user.id, - 'username': user.username, - 'roles': [{'id': r.id, 'name': r.name} for r in user.roles], - 'permissions': [{'id': p.id, 'name': p.name, 'description': p.description} for p in permissions] - }) + + return jsonify( + { + "user_id": user.id, + "username": user.username, + "roles": [{"id": r.id, "name": r.name} for r in user.roles], + "permissions": [{"id": p.id, "name": p.name, "description": p.description} for p in permissions], + } + ) -@permissions_bp.route('/api/roles//permissions') +@permissions_bp.route("/api/roles//permissions") @login_required @admin_required def get_role_permissions(role_id): """API endpoint to get role's permissions""" role = Role.query.get_or_404(role_id) - - return jsonify({ - 'role_id': role.id, - 'name': role.name, - 'description': role.description, - 'is_system_role': role.is_system_role, - 'permissions': [{'id': p.id, 'name': p.name, 'description': p.description, 'category': p.category} for p in role.permissions] - }) + return jsonify( + { + "role_id": role.id, + "name": role.name, + "description": role.description, + "is_system_role": role.is_system_role, + "permissions": [ + {"id": p.id, "name": p.name, "description": p.description, "category": p.category} + for p in role.permissions + ], + } + ) diff --git a/app/routes/project_templates.py b/app/routes/project_templates.py index a78f6f9e..c80f0c79 100644 --- a/app/routes/project_templates.py +++ b/app/routes/project_templates.py @@ -11,79 +11,79 @@ from app.utils.permissions import admin_or_permission_required import json -project_templates_bp = Blueprint('project_templates', __name__) +project_templates_bp = Blueprint("project_templates", __name__) -@project_templates_bp.route('/project-templates') +@project_templates_bp.route("/project-templates") @login_required def list_templates(): """List project templates""" - page = request.args.get('page', 1, type=int) - category = request.args.get('category', '').strip() - show_public = request.args.get('public', 'false').lower() == 'true' - + page = request.args.get("page", 1, type=int) + category = request.args.get("category", "").strip() + show_public = request.args.get("public", "false").lower() == "true" + service = ProjectTemplateService() - + result = service.list_templates( user_id=current_user.id, category=category if category else None, is_public=show_public if show_public else None, page=page, - per_page=20 + per_page=20, ) - + templates = result.items pagination = result - + # Get unique categories categories = db.session.query(ProjectTemplate.category).distinct().all() categories = [c[0] for c in categories if c[0]] - + return render_template( - 'project_templates/list.html', + "project_templates/list.html", templates=templates, pagination=pagination, categories=categories, current_category=category, - show_public=show_public + show_public=show_public, ) -@project_templates_bp.route('/project-templates/create', methods=['GET', 'POST']) +@project_templates_bp.route("/project-templates/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_projects') +@admin_or_permission_required("create_projects") def create_template(): """Create a new project template""" - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - category = request.form.get('category', '').strip() - is_public = request.form.get('is_public', 'false').lower() == 'true' - + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + is_public = request.form.get("is_public", "false").lower() == "true" + # Get config config = { - 'description': description, - 'billable': request.form.get('billable', 'true').lower() == 'true', - 'hourly_rate': request.form.get('hourly_rate') or None, - 'billing_ref': request.form.get('billing_ref', '').strip() or None, - 'code': request.form.get('code', '').strip().upper() or None, - 'estimated_hours': request.form.get('estimated_hours') or None, - 'budget_amount': request.form.get('budget_amount') or None, - 'budget_threshold_percent': int(request.form.get('budget_threshold_percent', 80)) + "description": description, + "billable": request.form.get("billable", "true").lower() == "true", + "hourly_rate": request.form.get("hourly_rate") or None, + "billing_ref": request.form.get("billing_ref", "").strip() or None, + "code": request.form.get("code", "").strip().upper() or None, + "estimated_hours": request.form.get("estimated_hours") or None, + "budget_amount": request.form.get("budget_amount") or None, + "budget_threshold_percent": int(request.form.get("budget_threshold_percent", 80)), } - + # Get tasks (from JSON or form) tasks = [] - tasks_json = request.form.get('tasks', '[]') + tasks_json = request.form.get("tasks", "[]") try: tasks = json.loads(tasks_json) except: pass - + # Get tags - tags_str = request.form.get('tags', '').strip() - tags = [t.strip() for t in tags_str.split(',') if t.strip()] - + tags_str = request.form.get("tags", "").strip() + tags = [t.strip() for t in tags_str.split(",") if t.strip()] + service = ProjectTemplateService() result = service.create_template( name=name, @@ -93,83 +93,83 @@ def create_template(): tasks=tasks, category=category or None, tags=tags, - is_public=is_public + is_public=is_public, ) - - if result['success']: - flash(_('Template created successfully.'), 'success') - return redirect(url_for('project_templates.list_templates')) + + if result["success"]: + flash(_("Template created successfully."), "success") + return redirect(url_for("project_templates.list_templates")) else: - flash(result['message'], 'error') - + flash(result["message"], "error") + clients = Client.get_active_clients() - return render_template('project_templates/create.html', clients=clients) + return render_template("project_templates/create.html", clients=clients) -@project_templates_bp.route('/project-templates/') +@project_templates_bp.route("/project-templates/") @login_required def view_template(template_id): """View a project template""" service = ProjectTemplateService() template = service.get_template(template_id) - + if not template: - flash(_('Template not found.'), 'error') - return redirect(url_for('project_templates.list_templates')) - + flash(_("Template not found."), "error") + return redirect(url_for("project_templates.list_templates")) + # Check permissions if not template.is_public and template.created_by != current_user.id: - flash(_('You do not have permission to view this template.'), 'error') - return redirect(url_for('project_templates.list_templates')) - - return render_template('project_templates/view.html', template=template) + flash(_("You do not have permission to view this template."), "error") + return redirect(url_for("project_templates.list_templates")) + return render_template("project_templates/view.html", template=template) -@project_templates_bp.route('/project-templates//edit', methods=['GET', 'POST']) + +@project_templates_bp.route("/project-templates//edit", methods=["GET", "POST"]) @login_required def edit_template(template_id): """Edit a project template""" service = ProjectTemplateService() template = service.get_template(template_id) - + if not template: - flash(_('Template not found.'), 'error') - return redirect(url_for('project_templates.list_templates')) - + flash(_("Template not found."), "error") + return redirect(url_for("project_templates.list_templates")) + if template.created_by != current_user.id: - flash(_('You do not have permission to edit this template.'), 'error') - return redirect(url_for('project_templates.view_template', template_id=template_id)) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - category = request.form.get('category', '').strip() - is_public = request.form.get('is_public', 'false').lower() == 'true' - + flash(_("You do not have permission to edit this template."), "error") + return redirect(url_for("project_templates.view_template", template_id=template_id)) + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + is_public = request.form.get("is_public", "false").lower() == "true" + # Get config config = { - 'description': description, - 'billable': request.form.get('billable', 'true').lower() == 'true', - 'hourly_rate': request.form.get('hourly_rate') or None, - 'billing_ref': request.form.get('billing_ref', '').strip() or None, - 'code': request.form.get('code', '').strip().upper() or None, - 'estimated_hours': request.form.get('estimated_hours') or None, - 'budget_amount': request.form.get('budget_amount') or None, - 'budget_threshold_percent': int(request.form.get('budget_threshold_percent', 80)) + "description": description, + "billable": request.form.get("billable", "true").lower() == "true", + "hourly_rate": request.form.get("hourly_rate") or None, + "billing_ref": request.form.get("billing_ref", "").strip() or None, + "code": request.form.get("code", "").strip().upper() or None, + "estimated_hours": request.form.get("estimated_hours") or None, + "budget_amount": request.form.get("budget_amount") or None, + "budget_threshold_percent": int(request.form.get("budget_threshold_percent", 80)), } - + # Get tasks tasks = [] - tasks_json = request.form.get('tasks', '[]') + tasks_json = request.form.get("tasks", "[]") try: tasks = json.loads(tasks_json) except: pass - + # Get tags - tags_str = request.form.get('tags', '').strip() - tags = [t.strip() for t in tags_str.split(',') if t.strip()] - + tags_str = request.form.get("tags", "").strip() + tags = [t.strip() for t in tags_str.split(",") if t.strip()] + result = service.update_template( template_id=template_id, user_id=current_user.id, @@ -179,77 +179,78 @@ def edit_template(template_id): tasks=tasks, category=category or None, tags=tags, - is_public=is_public + is_public=is_public, ) - - if result['success']: - flash(_('Template updated successfully.'), 'success') - return redirect(url_for('project_templates.view_template', template_id=template_id)) + + if result["success"]: + flash(_("Template updated successfully."), "success") + return redirect(url_for("project_templates.view_template", template_id=template_id)) else: - flash(result['message'], 'error') - + flash(result["message"], "error") + clients = Client.get_active_clients() - return render_template('project_templates/edit.html', template=template, clients=clients) + return render_template("project_templates/edit.html", template=template, clients=clients) -@project_templates_bp.route('/project-templates//delete', methods=['POST']) +@project_templates_bp.route("/project-templates//delete", methods=["POST"]) @login_required def delete_template(template_id): """Delete a project template""" service = ProjectTemplateService() result = service.delete_template(template_id, current_user.id) - - if result['success']: - flash(_('Template deleted successfully.'), 'success') + + if result["success"]: + flash(_("Template deleted successfully."), "success") else: - flash(result['message'], 'error') - - return redirect(url_for('project_templates.list_templates')) + flash(result["message"], "error") + + return redirect(url_for("project_templates.list_templates")) -@project_templates_bp.route('/project-templates//create-project', methods=['GET', 'POST']) +@project_templates_bp.route("/project-templates//create-project", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_projects') +@admin_or_permission_required("create_projects") def create_project_from_template(template_id): """Create a project from a template""" service = ProjectTemplateService() template = service.get_template(template_id) - + if not template: - flash(_('Template not found.'), 'error') - return redirect(url_for('project_templates.list_templates')) - - if request.method == 'POST': - client_id = request.form.get('client_id', type=int) - name = request.form.get('name', '').strip() - + flash(_("Template not found."), "error") + return redirect(url_for("project_templates.list_templates")) + + if request.method == "POST": + client_id = request.form.get("client_id", type=int) + name = request.form.get("name", "").strip() + if not client_id: - flash(_('Please select a client.'), 'error') - return render_template('project_templates/create_project.html', template=template, clients=Client.get_active_clients()) - + flash(_("Please select a client."), "error") + return render_template( + "project_templates/create_project.html", template=template, clients=Client.get_active_clients() + ) + # Get override config override_config = {} - if request.form.get('hourly_rate'): - override_config['hourly_rate'] = request.form.get('hourly_rate') - if request.form.get('billing_ref'): - override_config['billing_ref'] = request.form.get('billing_ref', '').strip() - if request.form.get('code'): - override_config['code'] = request.form.get('code', '').strip().upper() - + if request.form.get("hourly_rate"): + override_config["hourly_rate"] = request.form.get("hourly_rate") + if request.form.get("billing_ref"): + override_config["billing_ref"] = request.form.get("billing_ref", "").strip() + if request.form.get("code"): + override_config["code"] = request.form.get("code", "").strip().upper() + result = service.create_project_from_template( template_id=template_id, client_id=client_id, created_by=current_user.id, name=name or None, - override_config=override_config if override_config else None + override_config=override_config if override_config else None, ) - - if result['success']: - flash(_('Project created from template successfully.'), 'success') - return redirect(url_for('projects.view_project', project_id=result['project'].id)) + + if result["success"]: + flash(_("Project created from template successfully."), "success") + return redirect(url_for("projects.view_project", project_id=result["project"].id)) else: - flash(result['message'], 'error') - - clients = Client.get_active_clients() - return render_template('project_templates/create_project.html', template=template, clients=clients) + flash(result["message"], "error") + clients = Client.get_active_clients() + return render_template("project_templates/create_project.html", template=template, clients=clients) diff --git a/app/routes/projects.py b/app/routes/projects.py index d55c4588..f35efe76 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -1,8 +1,29 @@ -from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response, Response +from flask import ( + Blueprint, + render_template, + request, + redirect, + url_for, + flash, + current_app, + jsonify, + make_response, + Response, +) from flask_babel import gettext as _ from flask_login import login_required, current_user from app import db, log_event, track_event -from app.models import Project, TimeEntry, Task, Client, ProjectCost, KanbanColumn, ExtraGood, Activity, UserFavoriteProject +from app.models import ( + Project, + TimeEntry, + Task, + Client, + ProjectCost, + KanbanColumn, + ExtraGood, + Activity, + UserFavoriteProject, +) from datetime import datetime from decimal import Decimal from app.utils.db import safe_commit @@ -15,29 +36,31 @@ track_project_setup_started, track_project_setup_basic_info, track_project_setup_billing_configured, - track_project_setup_completed + track_project_setup_completed, ) -projects_bp = Blueprint('projects', __name__) +projects_bp = Blueprint("projects", __name__) + -@projects_bp.route('/projects') +@projects_bp.route("/projects") @login_required def list_projects(): """List all projects - REFACTORED to use service layer with eager loading""" # Track page view from app import track_page_view + track_page_view("projects_list") - + from app.services import ProjectService - - page = request.args.get('page', 1, type=int) - status = request.args.get('status', 'active') - client_name = request.args.get('client', '').strip() - search = request.args.get('search', '').strip() - favorites_only = request.args.get('favorites', '').lower() == 'true' - + + page = request.args.get("page", 1, type=int) + status = request.args.get("status", "active") + client_name = request.args.get("client", "").strip() + search = request.args.get("search", "").strip() + favorites_only = request.args.get("favorites", "").lower() == "true" + project_service = ProjectService() - + # Use service layer to get projects (prevents N+1 queries) result = project_service.list_projects( status=status, @@ -46,176 +69,182 @@ def list_projects(): favorites_only=favorites_only, user_id=current_user.id if favorites_only else None, page=page, - per_page=20 + per_page=20, ) - + # Get user's favorite project IDs for quick lookup in template favorite_project_ids = {p.id for p in current_user.favorite_projects.all()} - + # Get clients for filter dropdown clients = Client.get_active_clients() client_list = [c.name for c in clients] - + return render_template( - 'projects/list.html', - projects=result['projects'], - pagination=result['pagination'], + "projects/list.html", + projects=result["projects"], + pagination=result["pagination"], status=status, clients=client_list, favorite_project_ids=favorite_project_ids, - favorites_only=favorites_only + favorites_only=favorites_only, ) -@projects_bp.route('/projects/export') + +@projects_bp.route("/projects/export") @login_required def export_projects(): """Export projects to CSV""" - status = request.args.get('status', 'active') - client_name = request.args.get('client', '').strip() - search = request.args.get('search', '').strip() - favorites_only = request.args.get('favorites', '').lower() == 'true' - + status = request.args.get("status", "active") + client_name = request.args.get("client", "").strip() + search = request.args.get("search", "").strip() + favorites_only = request.args.get("favorites", "").lower() == "true" + query = Project.query - + # Filter by favorites if requested if favorites_only: query = query.join( UserFavoriteProject, - db.and_( - UserFavoriteProject.project_id == Project.id, - UserFavoriteProject.user_id == current_user.id - ) + db.and_(UserFavoriteProject.project_id == Project.id, UserFavoriteProject.user_id == current_user.id), ) - + # Filter by status - if status == 'active': - query = query.filter(Project.status == 'active') - elif status == 'archived': - query = query.filter(Project.status == 'archived') - elif status == 'inactive': - query = query.filter(Project.status == 'inactive') - + if status == "active": + query = query.filter(Project.status == "active") + elif status == "archived": + query = query.filter(Project.status == "archived") + elif status == "inactive": + query = query.filter(Project.status == "inactive") + if client_name: query = query.join(Client).filter(Client.name == client_name) - + if search: like = f"%{search}%" - query = query.filter( - db.or_( - Project.name.ilike(like), - Project.description.ilike(like) - ) - ) - + query = query.filter(db.or_(Project.name.ilike(like), Project.description.ilike(like))) + projects = query.order_by(Project.name).all() - + # Create CSV in memory output = io.StringIO() writer = csv.writer(output) - + # Write header - writer.writerow([ - 'ID', - 'Name', - 'Code', - 'Client', - 'Description', - 'Status', - 'Billable', - 'Hourly Rate', - 'Budget Amount', - 'Budget Threshold %', - 'Estimated Hours', - 'Billing Reference', - 'Created At', - 'Updated At' - ]) - + writer.writerow( + [ + "ID", + "Name", + "Code", + "Client", + "Description", + "Status", + "Billable", + "Hourly Rate", + "Budget Amount", + "Budget Threshold %", + "Estimated Hours", + "Billing Reference", + "Created At", + "Updated At", + ] + ) + # Write project data for project in projects: - writer.writerow([ - project.id, - project.name, - project.code or '', - project.client if project.client else '', - project.description or '', - project.status, - 'Yes' if project.billable else 'No', - project.hourly_rate or '', - project.budget_amount or '', - project.budget_threshold_percent or '', - project.estimated_hours or '', - project.billing_ref or '', - (convert_app_datetime_to_user(project.created_at, user=current_user).strftime('%Y-%m-%d %H:%M:%S') if project.created_at else ''), - (convert_app_datetime_to_user(project.updated_at, user=current_user).strftime('%Y-%m-%d %H:%M:%S') if hasattr(project, 'updated_at') and project.updated_at else '') - ]) - + writer.writerow( + [ + project.id, + project.name, + project.code or "", + project.client if project.client else "", + project.description or "", + project.status, + "Yes" if project.billable else "No", + project.hourly_rate or "", + project.budget_amount or "", + project.budget_threshold_percent or "", + project.estimated_hours or "", + project.billing_ref or "", + ( + convert_app_datetime_to_user(project.created_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S") + if project.created_at + else "" + ), + ( + convert_app_datetime_to_user(project.updated_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S") + if hasattr(project, "updated_at") and project.updated_at + else "" + ), + ] + ) + # Create response output.seek(0) return Response( output.getvalue(), - mimetype='text/csv', + mimetype="text/csv", headers={ - 'Content-Disposition': f'attachment; filename=projects_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' - } + "Content-Disposition": f'attachment; filename=projects_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + }, ) -@projects_bp.route('/projects/create', methods=['GET', 'POST']) + +@projects_bp.route("/projects/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_projects') +@admin_or_permission_required("create_projects") def create_project(): """Create a new project""" - + # Track project setup started when user opens the form - if request.method == 'GET': + if request.method == "GET": track_project_setup_started(current_user.id) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - client_id = request.form.get('client_id', '').strip() - description = request.form.get('description', '').strip() - billable = request.form.get('billable') == 'on' - hourly_rate = request.form.get('hourly_rate', '').strip() - billing_ref = request.form.get('billing_ref', '').strip() + + if request.method == "POST": + name = request.form.get("name", "").strip() + client_id = request.form.get("client_id", "").strip() + description = request.form.get("description", "").strip() + billable = request.form.get("billable") == "on" + hourly_rate = request.form.get("hourly_rate", "").strip() + billing_ref = request.form.get("billing_ref", "").strip() # Budgets - budget_amount_raw = request.form.get('budget_amount', '').strip() - budget_threshold_raw = request.form.get('budget_threshold_percent', '').strip() - code = request.form.get('code', '').strip() + budget_amount_raw = request.form.get("budget_amount", "").strip() + budget_threshold_raw = request.form.get("budget_threshold_percent", "").strip() + code = request.form.get("code", "").strip() try: current_app.logger.info( "POST /projects/create user=%s name=%s client_id=%s billable=%s", current_user.username, - name or '', - client_id or '', + name or "", + client_id or "", billable, ) except Exception: pass - + # Validate required fields if not name or not client_id: - flash(_('Project name and client are required'), 'error') + flash(_("Project name and client are required"), "error") try: current_app.logger.warning("Validation failed: missing required fields for project creation") except Exception: pass - return render_template('projects/create.html', clients=Client.get_active_clients()) - + return render_template("projects/create.html", clients=Client.get_active_clients()) + # Get client and validate client = Client.query.get(client_id) if not client: - flash(_('Selected client not found'), 'error') + flash(_("Selected client not found"), "error") try: current_app.logger.warning("Validation failed: client not found (id=%s)", client_id) except Exception: pass - return render_template('projects/create.html', clients=Client.get_active_clients()) - + return render_template("projects/create.html", clients=Client.get_active_clients()) + # Validate hourly rate try: hourly_rate = Decimal(hourly_rate) if hourly_rate else None except ValueError: - flash(_('Invalid hourly rate format'), 'error') + flash(_("Invalid hourly rate format"), "error") # Validate budgets budget_amount = None budget_threshold_percent = None @@ -223,28 +252,28 @@ def create_project(): try: budget_amount = Decimal(budget_amount_raw) if budget_amount < 0: - raise ValueError('Budget cannot be negative') + raise ValueError("Budget cannot be negative") except Exception: - flash(_('Invalid budget amount'), 'error') - return render_template('projects/create.html', clients=Client.get_active_clients()) + flash(_("Invalid budget amount"), "error") + return render_template("projects/create.html", clients=Client.get_active_clients()) if budget_threshold_raw: try: budget_threshold_percent = int(budget_threshold_raw) if budget_threshold_percent < 0 or budget_threshold_percent > 100: - raise ValueError('Invalid threshold') + raise ValueError("Invalid threshold") except Exception: - flash(_('Invalid budget threshold percent (0-100)'), 'error') - return render_template('projects/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid budget threshold percent (0-100)"), "error") + return render_template("projects/create.html", clients=Client.get_active_clients()) + # Check if project name already exists if Project.query.filter_by(name=name).first(): - flash(_('A project with this name already exists'), 'error') + flash(_("A project with this name already exists"), "error") try: current_app.logger.warning("Validation failed: duplicate project name '%s'", name) except Exception: pass - return render_template('projects/create.html', clients=Client.get_active_clients()) - + return render_template("projects/create.html", clients=Client.get_active_clients()) + # Normalize code normalized_code = code.upper() if code else None @@ -252,8 +281,8 @@ def create_project(): if normalized_code: existing_code = Project.query.filter(Project.code == normalized_code).first() if existing_code: - flash(_('Project code already in use'), 'error') - return render_template('projects/create.html', clients=Client.get_active_clients()) + flash(_("Project code already in use"), "error") + return render_template("projects/create.html", clients=Client.get_active_clients()) # Create project project = Project( @@ -265,292 +294,301 @@ def create_project(): billing_ref=billing_ref, code=normalized_code, budget_amount=budget_amount, - budget_threshold_percent=budget_threshold_percent or 80 + budget_threshold_percent=budget_threshold_percent or 80, ) - + db.session.add(project) - if not safe_commit('create_project', {'name': name, 'client_id': client_id}): - flash(_('Could not create project due to a database error. Please check server logs.'), 'error') - return render_template('projects/create.html', clients=Client.get_active_clients()) - + if not safe_commit("create_project", {"name": name, "client_id": client_id}): + flash(_("Could not create project due to a database error. Please check server logs."), "error") + return render_template("projects/create.html", clients=Client.get_active_clients()) + # Track project created event - log_event("project.created", - user_id=current_user.id, - project_id=project.id, - project_name=name, - has_client=bool(client_id)) - track_event(current_user.id, "project.created", { - "project_id": project.id, - "project_name": name, - "has_client": bool(client_id), - "billable": billable - }) - + log_event( + "project.created", + user_id=current_user.id, + project_id=project.id, + project_name=name, + has_client=bool(client_id), + ) + track_event( + current_user.id, + "project.created", + {"project_id": project.id, "project_name": name, "has_client": bool(client_id), "billable": billable}, + ) + # Track project setup funnel steps - track_project_setup_basic_info(current_user.id, { - "has_description": bool(description), - "has_code": bool(code), - "billable": billable - }) - + track_project_setup_basic_info( + current_user.id, {"has_description": bool(description), "has_code": bool(code), "billable": billable} + ) + if hourly_rate or billing_ref or budget_amount: - track_project_setup_billing_configured(current_user.id, { - "has_hourly_rate": bool(hourly_rate), - "has_billing_ref": bool(billing_ref), - "has_budget": bool(budget_amount) - }) - - track_project_setup_completed(current_user.id, { - "project_id": project.id, - "billable": billable, - "has_budget": bool(budget_amount) - }) - + track_project_setup_billing_configured( + current_user.id, + { + "has_hourly_rate": bool(hourly_rate), + "has_billing_ref": bool(billing_ref), + "has_budget": bool(budget_amount), + }, + ) + + track_project_setup_completed( + current_user.id, {"project_id": project.id, "billable": billable, "has_budget": bool(budget_amount)} + ) + # Check if this is user's first project (onboarding milestone) # Count projects this user has created or has time entries for from sqlalchemy import func, or_ - project_count = db.session.query(func.count(Project.id.distinct())).join( - TimeEntry, - TimeEntry.project_id == Project.id, - isouter=True - ).filter( - or_( - TimeEntry.user_id == current_user.id, - Project.id == project.id # Include the just-created project + + project_count = ( + db.session.query(func.count(Project.id.distinct())) + .join(TimeEntry, TimeEntry.project_id == Project.id, isouter=True) + .filter( + or_(TimeEntry.user_id == current_user.id, Project.id == project.id) # Include the just-created project ) - ).scalar() or 0 - + .scalar() + or 0 + ) + if project_count == 1: - track_onboarding_first_project(current_user.id, { - "project_name_length": len(name), - "has_description": bool(description), - "billable": billable, - "has_budget": bool(budget_amount) - }) - + track_onboarding_first_project( + current_user.id, + { + "project_name_length": len(name), + "has_description": bool(description), + "billable": billable, + "has_budget": bool(budget_amount), + }, + ) + # Log activity Activity.log( user_id=current_user.id, - action='created', - entity_type='project', + action="created", + entity_type="project", entity_id=project.id, entity_name=project.name, description=f'Created project "{project.name}" for {client.name}', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - - flash(f'Project "{name}" created successfully', 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('projects/create.html', clients=Client.get_active_clients()) -@projects_bp.route('/projects/') + flash(f'Project "{name}" created successfully', "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + + return render_template("projects/create.html", clients=Client.get_active_clients()) + + +@projects_bp.route("/projects/") @login_required def view_project(project_id): """View project details and time entries - REFACTORED to use service layer with eager loading""" from app.services import ProjectService - - page = request.args.get('page', 1, type=int) + + page = request.args.get("page", 1, type=int) project_service = ProjectService() - + # Get all project view data using service layer (prevents N+1 queries) result = project_service.get_project_view_data( - project_id=project_id, - time_entries_page=page, - time_entries_per_page=50 + project_id=project_id, time_entries_page=page, time_entries_per_page=50 ) - - if not result.get('success'): - flash(_('Project not found'), 'error') - return redirect(url_for('projects.list_projects')) - + + if not result.get("success"): + flash(_("Project not found"), "error") + return redirect(url_for("projects.list_projects")) + # Prevent browser caching of kanban board - response = render_template('projects/view.html', - project=result['project'], - entries=result['time_entries_pagination'].items, - pagination=result['time_entries_pagination'], - tasks=result['tasks'], - user_totals=result['user_totals'], - comments=result['comments'], - recent_costs=result['recent_costs'], - total_costs_count=result['total_costs_count'], - kanban_columns=result['kanban_columns']) + response = render_template( + "projects/view.html", + project=result["project"], + entries=result["time_entries_pagination"].items, + pagination=result["time_entries_pagination"], + tasks=result["tasks"], + user_totals=result["user_totals"], + comments=result["comments"], + recent_costs=result["recent_costs"], + total_costs_count=result["total_costs_count"], + kanban_columns=result["kanban_columns"], + ) resp = make_response(response) - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' - resp.headers['Pragma'] = 'no-cache' - resp.headers['Expires'] = '0' + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" return resp -@projects_bp.route('/projects//dashboard') + +@projects_bp.route("/projects//dashboard") @login_required def project_dashboard(project_id): """Project dashboard with comprehensive analytics and visualizations""" project = Project.query.get_or_404(project_id) - + # Track page view from app import track_page_view + track_page_view("project_dashboard") - + # Get time period filter (default to all time) from datetime import datetime, timedelta - period = request.args.get('period', 'all') + + period = request.args.get("period", "all") start_date = None end_date = None - - if period == 'week': + + if period == "week": start_date = datetime.now() - timedelta(days=7) - elif period == 'month': + elif period == "month": start_date = datetime.now() - timedelta(days=30) - elif period == '3months': + elif period == "3months": start_date = datetime.now() - timedelta(days=90) - elif period == 'year': + elif period == "year": start_date = datetime.now() - timedelta(days=365) - + # === Budget vs Actual === budget_data = { - 'budget_amount': float(project.budget_amount) if project.budget_amount else 0, - 'consumed_amount': project.budget_consumed_amount, - 'remaining_amount': float(project.budget_amount or 0) - project.budget_consumed_amount, - 'percentage': round((project.budget_consumed_amount / float(project.budget_amount or 1)) * 100, 1) if project.budget_amount else 0, - 'threshold_exceeded': project.budget_threshold_exceeded, - 'estimated_hours': project.estimated_hours or 0, - 'actual_hours': project.actual_hours, - 'remaining_hours': (project.estimated_hours or 0) - project.actual_hours, - 'hours_percentage': round((project.actual_hours / (project.estimated_hours or 1)) * 100, 1) if project.estimated_hours else 0 + "budget_amount": float(project.budget_amount) if project.budget_amount else 0, + "consumed_amount": project.budget_consumed_amount, + "remaining_amount": float(project.budget_amount or 0) - project.budget_consumed_amount, + "percentage": ( + round((project.budget_consumed_amount / float(project.budget_amount or 1)) * 100, 1) + if project.budget_amount + else 0 + ), + "threshold_exceeded": project.budget_threshold_exceeded, + "estimated_hours": project.estimated_hours or 0, + "actual_hours": project.actual_hours, + "remaining_hours": (project.estimated_hours or 0) - project.actual_hours, + "hours_percentage": ( + round((project.actual_hours / (project.estimated_hours or 1)) * 100, 1) if project.estimated_hours else 0 + ), } - + # === Task Statistics === all_tasks = project.tasks.all() task_stats = { - 'total': len(all_tasks), - 'by_status': {}, - 'completed': 0, - 'in_progress': 0, - 'todo': 0, - 'completion_rate': 0, - 'overdue': 0 + "total": len(all_tasks), + "by_status": {}, + "completed": 0, + "in_progress": 0, + "todo": 0, + "completion_rate": 0, + "overdue": 0, } - + for task in all_tasks: status = task.status - task_stats['by_status'][status] = task_stats['by_status'].get(status, 0) + 1 - if status == 'done': - task_stats['completed'] += 1 - elif status == 'in_progress': - task_stats['in_progress'] += 1 - elif status == 'todo': - task_stats['todo'] += 1 + task_stats["by_status"][status] = task_stats["by_status"].get(status, 0) + 1 + if status == "done": + task_stats["completed"] += 1 + elif status == "in_progress": + task_stats["in_progress"] += 1 + elif status == "todo": + task_stats["todo"] += 1 if task.is_overdue: - task_stats['overdue'] += 1 - - if task_stats['total'] > 0: - task_stats['completion_rate'] = round((task_stats['completed'] / task_stats['total']) * 100, 1) - + task_stats["overdue"] += 1 + + if task_stats["total"] > 0: + task_stats["completion_rate"] = round((task_stats["completed"] / task_stats["total"]) * 100, 1) + # === Team Member Contributions === user_totals = project.get_user_totals(start_date=start_date, end_date=end_date) - + # Get time entries per user with additional stats from app.models import User + team_contributions = [] for user_data in user_totals: - username = user_data['username'] - total_hours = user_data['total_hours'] - + username = user_data["username"] + total_hours = user_data["total_hours"] + # Get user object - user = User.query.filter( - db.or_( - User.username == username, - User.full_name == username - ) - ).first() - + user = User.query.filter(db.or_(User.username == username, User.full_name == username)).first() + if user: # Count entries for this user - entry_count = project.time_entries.filter( - TimeEntry.user_id == user.id, - TimeEntry.end_time.isnot(None) - ) + entry_count = project.time_entries.filter(TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None)) if start_date: entry_count = entry_count.filter(TimeEntry.start_time >= start_date) if end_date: entry_count = entry_count.filter(TimeEntry.start_time <= end_date) entry_count = entry_count.count() - + # Count tasks assigned to this user task_count = project.tasks.filter_by(assigned_to=user.id).count() - - team_contributions.append({ - 'username': username, - 'total_hours': total_hours, - 'entry_count': entry_count, - 'task_count': task_count, - 'percentage': round((total_hours / project.total_hours * 100), 1) if project.total_hours > 0 else 0 - }) - + + team_contributions.append( + { + "username": username, + "total_hours": total_hours, + "entry_count": entry_count, + "task_count": task_count, + "percentage": round((total_hours / project.total_hours * 100), 1) if project.total_hours > 0 else 0, + } + ) + # Sort by total hours descending - team_contributions.sort(key=lambda x: x['total_hours'], reverse=True) - + team_contributions.sort(key=lambda x: x["total_hours"], reverse=True) + # === Recent Activity === - recent_activities = Activity.query.filter( - Activity.entity_type.in_(['project', 'task', 'time_entry']), - db.or_( - Activity.entity_id == project_id, - db.and_( - Activity.entity_type == 'task', - Activity.entity_id.in_([t.id for t in all_tasks]) - ) + recent_activities = ( + Activity.query.filter( + Activity.entity_type.in_(["project", "task", "time_entry"]), + db.or_( + Activity.entity_id == project_id, + db.and_(Activity.entity_type == "task", Activity.entity_id.in_([t.id for t in all_tasks])), + ), ) - ).order_by(Activity.created_at.desc()).limit(20).all() - + .order_by(Activity.created_at.desc()) + .limit(20) + .all() + ) + # Filter to only project-related activities project_activities = [] for activity in recent_activities: - if activity.entity_type == 'project' and activity.entity_id == project_id: + if activity.entity_type == "project" and activity.entity_id == project_id: project_activities.append(activity) - elif activity.entity_type == 'task': + elif activity.entity_type == "task": # Check if task belongs to this project task = Task.query.get(activity.entity_id) if task and task.project_id == project_id: project_activities.append(activity) - + # === Time Tracking Timeline (last 30 days) === from sqlalchemy import func + timeline_data = [] - if start_date or period != 'all': + if start_date or period != "all": timeline_start = start_date or (datetime.now() - timedelta(days=30)) - + # Group time entries by date - daily_hours = db.session.query( - func.date(TimeEntry.start_time).label('date'), - func.sum(TimeEntry.duration_seconds).label('total_seconds') - ).filter( - TimeEntry.project_id == project_id, - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= timeline_start - ).group_by(func.date(TimeEntry.start_time)).order_by('date').all() - + daily_hours = ( + db.session.query( + func.date(TimeEntry.start_time).label("date"), + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + ) + .filter( + TimeEntry.project_id == project_id, + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= timeline_start, + ) + .group_by(func.date(TimeEntry.start_time)) + .order_by("date") + .all() + ) + timeline_data = [ - { - 'date': str(date), - 'hours': round(total_seconds / 3600, 2) - } - for date, total_seconds in daily_hours + {"date": str(date), "hours": round(total_seconds / 3600, 2)} for date, total_seconds in daily_hours ] - + # === Cost Breakdown === - cost_data = { - 'total_costs': project.total_costs, - 'billable_costs': project.total_billable_costs, - 'by_category': {} - } - - if hasattr(ProjectCost, 'get_costs_by_category'): + cost_data = {"total_costs": project.total_costs, "billable_costs": project.total_billable_costs, "by_category": {}} + + if hasattr(ProjectCost, "get_costs_by_category"): cost_breakdown = ProjectCost.get_costs_by_category(project_id, start_date, end_date) - cost_data['by_category'] = cost_breakdown - + cost_data["by_category"] = cost_breakdown + return render_template( - 'projects/dashboard.html', + "projects/dashboard.html", project=project, budget_data=budget_data, task_stats=task_stats, @@ -558,44 +596,45 @@ def project_dashboard(project_id): recent_activities=project_activities[:10], timeline_data=timeline_data, cost_data=cost_data, - period=period + period=period, ) -@projects_bp.route('/projects//edit', methods=['GET', 'POST']) + +@projects_bp.route("/projects//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('edit_projects') +@admin_or_permission_required("edit_projects") def edit_project(project_id): """Edit project details""" project = Project.query.get_or_404(project_id) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - client_id = request.form.get('client_id', '').strip() - description = request.form.get('description', '').strip() - billable = request.form.get('billable') == 'on' - hourly_rate = request.form.get('hourly_rate', '').strip() - billing_ref = request.form.get('billing_ref', '').strip() - code = request.form.get('code', '').strip() - budget_amount_raw = request.form.get('budget_amount', '').strip() - budget_threshold_raw = request.form.get('budget_threshold_percent', '').strip() - + + if request.method == "POST": + name = request.form.get("name", "").strip() + client_id = request.form.get("client_id", "").strip() + description = request.form.get("description", "").strip() + billable = request.form.get("billable") == "on" + hourly_rate = request.form.get("hourly_rate", "").strip() + billing_ref = request.form.get("billing_ref", "").strip() + code = request.form.get("code", "").strip() + budget_amount_raw = request.form.get("budget_amount", "").strip() + budget_threshold_raw = request.form.get("budget_threshold_percent", "").strip() + # Validate required fields if not name or not client_id: - flash(_('Project name and client are required'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) - + flash(_("Project name and client are required"), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) + # Get client and validate client = Client.query.get(client_id) if not client: - flash(_('Selected client not found'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) - + flash(_("Selected client not found"), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) + # Validate hourly rate try: hourly_rate = Decimal(hourly_rate) if hourly_rate else None except ValueError: - flash(_('Invalid hourly rate format'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) + flash(_("Invalid hourly rate format"), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) # Validate budgets budget_amount = None @@ -603,34 +642,34 @@ def edit_project(project_id): try: budget_amount = Decimal(budget_amount_raw) if budget_amount < 0: - raise ValueError('Budget cannot be negative') + raise ValueError("Budget cannot be negative") except Exception: - flash(_('Invalid budget amount'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) + flash(_("Invalid budget amount"), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) budget_threshold_percent = project.budget_threshold_percent or 80 if budget_threshold_raw: try: budget_threshold_percent = int(budget_threshold_raw) if budget_threshold_percent < 0 or budget_threshold_percent > 100: - raise ValueError('Invalid threshold') + raise ValueError("Invalid threshold") except Exception: - flash(_('Invalid budget threshold percent (0-100)'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) - + flash(_("Invalid budget threshold percent (0-100)"), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) + # Check if project name already exists (excluding current project) existing = Project.query.filter_by(name=name).first() if existing and existing.id != project.id: - flash(_('A project with this name already exists'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) + flash(_("A project with this name already exists"), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) # Validate code uniqueness if provided normalized_code = code.upper() if code else None if normalized_code: existing_code = Project.query.filter(Project.code == normalized_code).first() if existing_code and existing_code.id != project.id: - flash(_('Project code already in use'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) - + flash(_("Project code already in use"), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) + # Update project project.name = name project.client_id = client_id @@ -639,302 +678,306 @@ def edit_project(project_id): project.hourly_rate = hourly_rate project.billing_ref = billing_ref project.code = normalized_code - project.budget_amount = budget_amount if budget_amount_raw != '' else None + project.budget_amount = budget_amount if budget_amount_raw != "" else None project.budget_threshold_percent = budget_threshold_percent project.updated_at = datetime.utcnow() - - if not safe_commit('edit_project', {'project_id': project.id}): - flash(_('Could not update project due to a database error. Please check server logs.'), 'error') - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) - + + if not safe_commit("edit_project", {"project_id": project.id}): + flash(_("Could not update project due to a database error. Please check server logs."), "error") + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) + # Log activity Activity.log( user_id=current_user.id, - action='updated', - entity_type='project', + action="updated", + entity_type="project", entity_id=project.id, entity_name=project.name, description=f'Updated project "{project.name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - - flash(f'Project "{name}" updated successfully', 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('projects/edit.html', project=project, clients=Client.get_active_clients()) -@projects_bp.route('/projects//archive', methods=['GET', 'POST']) + flash(f'Project "{name}" updated successfully', "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + + return render_template("projects/edit.html", project=project, clients=Client.get_active_clients()) + + +@projects_bp.route("/projects//archive", methods=["GET", "POST"]) @login_required def archive_project(project_id): """Archive a project with optional reason""" project = Project.query.get_or_404(project_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('archive_projects'): - flash(_('You do not have permission to archive projects'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - if request.method == 'GET': + if not current_user.is_admin and not current_user.has_permission("archive_projects"): + flash(_("You do not have permission to archive projects"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + if request.method == "GET": # Show archive form - return render_template('projects/archive.html', project=project) - - if project.status == 'archived': - flash(_('Project is already archived'), 'info') + return render_template("projects/archive.html", project=project) + + if project.status == "archived": + flash(_("Project is already archived"), "info") else: - reason = request.form.get('reason', '').strip() + reason = request.form.get("reason", "").strip() project.archive(user_id=current_user.id, reason=reason if reason else None) - + # Log the archiving - log_event("project.archived", - user_id=current_user.id, - project_id=project.id, - reason=reason if reason else None) - track_event(current_user.id, "project.archived", { - "project_id": project.id, - "has_reason": bool(reason) - }) - + log_event("project.archived", user_id=current_user.id, project_id=project.id, reason=reason if reason else None) + track_event(current_user.id, "project.archived", {"project_id": project.id, "has_reason": bool(reason)}) + # Log activity Activity.log( user_id=current_user.id, - action='archived', - entity_type='project', + action="archived", + entity_type="project", entity_id=project.id, entity_name=project.name, - description=f'Archived project "{project.name}"' + (f': {reason}' if reason else ''), + description=f'Archived project "{project.name}"' + (f": {reason}" if reason else ""), ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - - flash(f'Project "{project.name}" archived successfully', 'success') - - return redirect(url_for('projects.list_projects', status='archived')) -@projects_bp.route('/projects//unarchive', methods=['POST']) + flash(f'Project "{project.name}" archived successfully', "success") + + return redirect(url_for("projects.list_projects", status="archived")) + + +@projects_bp.route("/projects//unarchive", methods=["POST"]) @login_required def unarchive_project(project_id): """Unarchive a project""" project = Project.query.get_or_404(project_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('archive_projects'): - flash(_('You do not have permission to unarchive projects'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - if project.status == 'active': - flash(_('Project is already active'), 'info') + if not current_user.is_admin and not current_user.has_permission("archive_projects"): + flash(_("You do not have permission to unarchive projects"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + if project.status == "active": + flash(_("Project is already active"), "info") else: project.unarchive() - + # Log the unarchiving log_event("project.unarchived", user_id=current_user.id, project_id=project.id) track_event(current_user.id, "project.unarchived", {"project_id": project.id}) - + # Log activity Activity.log( user_id=current_user.id, - action='unarchived', - entity_type='project', + action="unarchived", + entity_type="project", entity_id=project.id, entity_name=project.name, description=f'Unarchived project "{project.name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - - flash(f'Project "{project.name}" unarchived successfully', 'success') - - return redirect(url_for('projects.list_projects')) -@projects_bp.route('/projects//deactivate', methods=['POST']) + flash(f'Project "{project.name}" unarchived successfully', "success") + + return redirect(url_for("projects.list_projects")) + + +@projects_bp.route("/projects//deactivate", methods=["POST"]) @login_required def deactivate_project(project_id): """Mark a project as inactive""" project = Project.query.get_or_404(project_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_projects'): - flash(_('You do not have permission to deactivate projects'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - if project.status == 'inactive': - flash(_('Project is already inactive'), 'info') + if not current_user.is_admin and not current_user.has_permission("edit_projects"): + flash(_("You do not have permission to deactivate projects"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + if project.status == "inactive": + flash(_("Project is already inactive"), "info") else: project.deactivate() # Log project deactivation log_event("project.deactivated", user_id=current_user.id, project_id=project.id) track_event(current_user.id, "project.deactivated", {"project_id": project.id}) - flash(f'Project "{project.name}" marked as inactive', 'success') - - return redirect(url_for('projects.list_projects')) + flash(f'Project "{project.name}" marked as inactive', "success") + + return redirect(url_for("projects.list_projects")) -@projects_bp.route('/projects//activate', methods=['POST']) + +@projects_bp.route("/projects//activate", methods=["POST"]) @login_required def activate_project(project_id): """Activate a project""" project = Project.query.get_or_404(project_id) - + # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_projects'): - flash(_('You do not have permission to activate projects'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - if project.status == 'active': - flash(_('Project is already active'), 'info') + if not current_user.is_admin and not current_user.has_permission("edit_projects"): + flash(_("You do not have permission to activate projects"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + if project.status == "active": + flash(_("Project is already active"), "info") else: project.activate() # Log project activation log_event("project.activated", user_id=current_user.id, project_id=project.id) track_event(current_user.id, "project.activated", {"project_id": project.id}) - flash(f'Project "{project.name}" activated successfully', 'success') - - return redirect(url_for('projects.list_projects')) + flash(f'Project "{project.name}" activated successfully', "success") + + return redirect(url_for("projects.list_projects")) -@projects_bp.route('/projects//delete', methods=['POST']) + +@projects_bp.route("/projects//delete", methods=["POST"]) @login_required -@admin_or_permission_required('delete_projects') +@admin_or_permission_required("delete_projects") def delete_project(project_id): """Delete a project (only if no time entries exist)""" project = Project.query.get_or_404(project_id) - + # Check if project has time entries if project.time_entries.count() > 0: - flash(_('Cannot delete project with existing time entries'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("Cannot delete project with existing time entries"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + project_name = project.name project_id_copy = project.id - + # Log activity before deletion Activity.log( user_id=current_user.id, - action='deleted', - entity_type='project', + action="deleted", + entity_type="project", entity_id=project_id_copy, entity_name=project_name, description=f'Deleted project "{project_name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + db.session.delete(project) - if not safe_commit('delete_project', {'project_id': project_id_copy}): - flash(_('Could not delete project due to a database error. Please check server logs.'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id_copy)) - - flash(f'Project "{project_name}" deleted successfully', 'success') - return redirect(url_for('projects.list_projects')) - -@projects_bp.route('/projects/bulk-delete', methods=['POST']) + if not safe_commit("delete_project", {"project_id": project_id_copy}): + flash(_("Could not delete project due to a database error. Please check server logs."), "error") + return redirect(url_for("projects.view_project", project_id=project_id_copy)) + + flash(f'Project "{project_name}" deleted successfully', "success") + return redirect(url_for("projects.list_projects")) + + +@projects_bp.route("/projects/bulk-delete", methods=["POST"]) @login_required def bulk_delete_projects(): """Delete multiple projects at once""" # Check permissions - if not current_user.is_admin and not current_user.has_permission('delete_projects'): - flash(_('You do not have permission to delete projects'), 'error') - return redirect(url_for('projects.list_projects')) - - project_ids = request.form.getlist('project_ids[]') - + if not current_user.is_admin and not current_user.has_permission("delete_projects"): + flash(_("You do not have permission to delete projects"), "error") + return redirect(url_for("projects.list_projects")) + + project_ids = request.form.getlist("project_ids[]") + if not project_ids: - flash(_('No projects selected for deletion'), 'warning') - return redirect(url_for('projects.list_projects')) - + flash(_("No projects selected for deletion"), "warning") + return redirect(url_for("projects.list_projects")) + deleted_count = 0 skipped_count = 0 errors = [] - + for project_id_str in project_ids: try: project_id = int(project_id_str) project = Project.query.get(project_id) - + if not project: continue - + # Check for time entries if project.time_entries.count() > 0: skipped_count += 1 errors.append(f"'{project.name}': Has time entries") continue - + # Delete the project project_id_for_log = project.id project_name = project.name - + db.session.delete(project) deleted_count += 1 - + # Log the deletion log_event("project.deleted", user_id=current_user.id, project_id=project_id_for_log) track_event(current_user.id, "project.deleted", {"project_id": project_id_for_log}) - + except Exception as e: skipped_count += 1 errors.append(f"ID {project_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: - if not safe_commit('bulk_delete_projects', {'count': deleted_count}): - flash(_('Could not delete projects due to a database error. Please check server logs.'), 'error') - return redirect(url_for('projects.list_projects')) - + if not safe_commit("bulk_delete_projects", {"count": deleted_count}): + flash(_("Could not delete projects due to a database error. Please check server logs."), "error") + return redirect(url_for("projects.list_projects")) + # Show appropriate messages if deleted_count > 0: - flash(f'Successfully deleted {deleted_count} project{"s" if deleted_count != 1 else ""}', 'success') - + flash(f'Successfully deleted {deleted_count} project{"s" if deleted_count != 1 else ""}', "success") + if skipped_count > 0: - flash(f'Skipped {skipped_count} project{"s" if skipped_count != 1 else ""}: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning') - + flash( + f'Skipped {skipped_count} project{"s" if skipped_count != 1 else ""}: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', + "warning", + ) + if deleted_count == 0 and skipped_count == 0: - flash(_('No projects were deleted'), 'info') - - return redirect(url_for('projects.list_projects')) + flash(_("No projects were deleted"), "info") + + return redirect(url_for("projects.list_projects")) -@projects_bp.route('/projects/bulk-status-change', methods=['POST']) + +@projects_bp.route("/projects/bulk-status-change", methods=["POST"]) @login_required def bulk_status_change(): """Change status for multiple projects at once""" # Check permissions - if not current_user.is_admin and not current_user.has_permission('edit_projects'): - flash(_('You do not have permission to change project status'), 'error') - return redirect(url_for('projects.list_projects')) - - project_ids = request.form.getlist('project_ids[]') - new_status = request.form.get('new_status', '').strip() - archive_reason = request.form.get('archive_reason', '').strip() if new_status == 'archived' else None - + if not current_user.is_admin and not current_user.has_permission("edit_projects"): + flash(_("You do not have permission to change project status"), "error") + return redirect(url_for("projects.list_projects")) + + project_ids = request.form.getlist("project_ids[]") + new_status = request.form.get("new_status", "").strip() + archive_reason = request.form.get("archive_reason", "").strip() if new_status == "archived" else None + if not project_ids: - flash(_('No projects selected'), 'warning') - return redirect(url_for('projects.list_projects')) - - if new_status not in ['active', 'inactive', 'archived']: - flash(_('Invalid status'), 'error') - return redirect(url_for('projects.list_projects')) - + flash(_("No projects selected"), "warning") + return redirect(url_for("projects.list_projects")) + + if new_status not in ["active", "inactive", "archived"]: + flash(_("Invalid status"), "error") + return redirect(url_for("projects.list_projects")) + updated_count = 0 errors = [] - + for project_id_str in project_ids: try: project_id = int(project_id_str) project = Project.query.get(project_id) - + if not project: continue - + # Update status based on type - if new_status == 'archived': + if new_status == "archived": # Use the enhanced archive method - project.status = 'archived' + project.status = "archived" project.archived_at = datetime.utcnow() project.archived_by = current_user.id project.archived_reason = archive_reason if archive_reason else None project.updated_at = datetime.utcnow() - elif new_status == 'active': + elif new_status == "active": # Clear archiving metadata when activating - project.status = 'active' + project.status = "active" project.archived_at = None project.archived_by = None project.archived_reason = None @@ -943,234 +986,240 @@ def bulk_status_change(): # Just update status for inactive project.status = new_status project.updated_at = datetime.utcnow() - + updated_count += 1 - + # Log the status change log_event(f"project.status_changed_{new_status}", user_id=current_user.id, project_id=project.id) track_event(current_user.id, "project.status_changed", {"project_id": project.id, "new_status": new_status}) - + # Log activity Activity.log( user_id=current_user.id, - action=f'status_changed_{new_status}', - entity_type='project', + action=f"status_changed_{new_status}", + entity_type="project", entity_id=project.id, entity_name=project.name, - description=f'Changed project "{project.name}" status to {new_status}' + (f': {archive_reason}' if new_status == 'archived' and archive_reason else ''), + description=f'Changed project "{project.name}" status to {new_status}' + + (f": {archive_reason}" if new_status == "archived" and archive_reason else ""), ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + except Exception as e: errors.append(f"ID {project_id_str}: {str(e)}") - + # Commit all changes if updated_count > 0: - if not safe_commit('bulk_status_change_projects', {'count': updated_count, 'status': new_status}): - flash(_('Could not update project status due to a database error. Please check server logs.'), 'error') - return redirect(url_for('projects.list_projects')) - + if not safe_commit("bulk_status_change_projects", {"count": updated_count, "status": new_status}): + flash(_("Could not update project status due to a database error. Please check server logs."), "error") + return redirect(url_for("projects.list_projects")) + # Show appropriate messages - status_labels = {'active': 'active', 'inactive': 'inactive', 'archived': 'archived'} + status_labels = {"active": "active", "inactive": "inactive", "archived": "archived"} if updated_count > 0: - flash(f'Successfully marked {updated_count} project{"s" if updated_count != 1 else ""} as {status_labels.get(new_status, new_status)}', 'success') - + flash( + f'Successfully marked {updated_count} project{"s" if updated_count != 1 else ""} as {status_labels.get(new_status, new_status)}', + "success", + ) + if errors: - flash(f'Some projects could not be updated: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', 'warning') - + flash( + f'Some projects could not be updated: {", ".join(errors[:3])}{"..." if len(errors) > 3 else ""}', "warning" + ) + if updated_count == 0: - flash(_('No projects were updated'), 'info') - - return redirect(url_for('projects.list_projects')) + flash(_("No projects were updated"), "info") + + return redirect(url_for("projects.list_projects")) # ===== FAVORITE PROJECTS ROUTES ===== -@projects_bp.route('/projects//favorite', methods=['POST']) + +@projects_bp.route("/projects//favorite", methods=["POST"]) @login_required def favorite_project(project_id): """Add a project to user's favorites""" project = Project.query.get_or_404(project_id) - + try: # Check if already favorited if current_user.is_project_favorite(project): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': _('Project is already in favorites')}), 200 - flash(_('Project is already in favorites'), 'info') + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "message": _("Project is already in favorites")}), 200 + flash(_("Project is already in favorites"), "info") else: # Add to favorites current_user.add_favorite_project(project) - + # Log activity Activity.log( user_id=current_user.id, - action='favorited', - entity_type='project', + action="favorited", + entity_type="project", entity_id=project.id, entity_name=project.name, description=f'Added project "{project.name}" to favorites', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event log_event("project.favorited", user_id=current_user.id, project_id=project.id) track_event(current_user.id, "project.favorited", {"project_id": project.id}) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': True, 'message': _('Project added to favorites')}), 200 - flash(_('Project added to favorites'), 'success') + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": True, "message": _("Project added to favorites")}), 200 + flash(_("Project added to favorites"), "success") except Exception as e: current_app.logger.error(f"Error favoriting project: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': _('Failed to add project to favorites')}), 500 - flash(_('Failed to add project to favorites'), 'error') - + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "message": _("Failed to add project to favorites")}), 500 + flash(_("Failed to add project to favorites"), "error") + # Redirect back to referrer or project list - return redirect(request.referrer or url_for('projects.list_projects')) + return redirect(request.referrer or url_for("projects.list_projects")) -@projects_bp.route('/projects//unfavorite', methods=['POST']) +@projects_bp.route("/projects//unfavorite", methods=["POST"]) @login_required def unfavorite_project(project_id): """Remove a project from user's favorites""" project = Project.query.get_or_404(project_id) - + try: # Check if not favorited if not current_user.is_project_favorite(project): - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': _('Project is not in favorites')}), 200 - flash(_('Project is not in favorites'), 'info') + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "message": _("Project is not in favorites")}), 200 + flash(_("Project is not in favorites"), "info") else: # Remove from favorites current_user.remove_favorite_project(project) - + # Log activity Activity.log( user_id=current_user.id, - action='unfavorited', - entity_type='project', + action="unfavorited", + entity_type="project", entity_id=project.id, entity_name=project.name, description=f'Removed project "{project.name}" from favorites', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event log_event("project.unfavorited", user_id=current_user.id, project_id=project.id) track_event(current_user.id, "project.unfavorited", {"project_id": project.id}) - - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': True, 'message': _('Project removed from favorites')}), 200 - flash(_('Project removed from favorites'), 'success') + + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": True, "message": _("Project removed from favorites")}), 200 + flash(_("Project removed from favorites"), "success") except Exception as e: current_app.logger.error(f"Error unfavoriting project: {e}") - if request.headers.get('X-Requested-With') == 'XMLHttpRequest': - return jsonify({'success': False, 'message': _('Failed to remove project from favorites')}), 500 - flash(_('Failed to remove project from favorites'), 'error') - + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": False, "message": _("Failed to remove project from favorites")}), 500 + flash(_("Failed to remove project from favorites"), "error") + # Redirect back to referrer or project list - return redirect(request.referrer or url_for('projects.list_projects')) + return redirect(request.referrer or url_for("projects.list_projects")) # ===== PROJECT COSTS ROUTES ===== -@projects_bp.route('/projects//costs') + +@projects_bp.route("/projects//costs") @login_required def list_costs(project_id): """List all costs for a project""" project = Project.query.get_or_404(project_id) - + # Get filters from query params - start_date_str = request.args.get('start_date', '') - end_date_str = request.args.get('end_date', '') - category = request.args.get('category', '') - + start_date_str = request.args.get("start_date", "") + end_date_str = request.args.get("end_date", "") + category = request.args.get("category", "") + start_date = None end_date = None - + if start_date_str: try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() except ValueError: pass - + if end_date_str: try: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() except ValueError: pass - + # Get costs query = project.costs - + if start_date: query = query.filter(ProjectCost.cost_date >= start_date) - + if end_date: query = query.filter(ProjectCost.cost_date <= end_date) - + if category: query = query.filter(ProjectCost.category == category) - + costs = query.order_by(ProjectCost.cost_date.desc()).all() - + # Get category breakdown - category_breakdown = ProjectCost.get_costs_by_category( - project_id, start_date, end_date - ) - + category_breakdown = ProjectCost.get_costs_by_category(project_id, start_date, end_date) + return render_template( - 'projects/costs.html', + "projects/costs.html", project=project, costs=costs, category_breakdown=category_breakdown, start_date=start_date_str, end_date=end_date_str, - selected_category=category + selected_category=category, ) -@projects_bp.route('/projects//costs/add', methods=['GET', 'POST']) +@projects_bp.route("/projects//costs/add", methods=["GET", "POST"]) @login_required def add_cost(project_id): """Add a new cost to a project""" project = Project.query.get_or_404(project_id) - - if request.method == 'POST': - description = request.form.get('description', '').strip() - category = request.form.get('category', '').strip() - amount = request.form.get('amount', '').strip() - cost_date_str = request.form.get('cost_date', '').strip() - billable = request.form.get('billable') == 'on' - notes = request.form.get('notes', '').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - + + if request.method == "POST": + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + amount = request.form.get("amount", "").strip() + cost_date_str = request.form.get("cost_date", "").strip() + billable = request.form.get("billable") == "on" + notes = request.form.get("notes", "").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + # Validate required fields if not description or not category or not amount or not cost_date_str: - flash(_('Description, category, amount, and date are required'), 'error') - return render_template('projects/add_cost.html', project=project) - + flash(_("Description, category, amount, and date are required"), "error") + return render_template("projects/add_cost.html", project=project) + # Validate amount try: amount = Decimal(amount) if amount <= 0: - raise ValueError('Amount must be positive') + raise ValueError("Amount must be positive") except (ValueError, Exception): - flash(_('Invalid amount format'), 'error') - return render_template('projects/add_cost.html', project=project) - + flash(_("Invalid amount format"), "error") + return render_template("projects/add_cost.html", project=project) + # Validate date try: - cost_date = datetime.strptime(cost_date_str, '%Y-%m-%d').date() + cost_date = datetime.strptime(cost_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return render_template('projects/add_cost.html', project=project) - + flash(_("Invalid date format"), "error") + return render_template("projects/add_cost.html", project=project) + # Create cost cost = ProjectCost( project_id=project_id, @@ -1181,67 +1230,67 @@ def add_cost(project_id): cost_date=cost_date, billable=billable, notes=notes, - currency_code=currency_code + currency_code=currency_code, ) - + db.session.add(cost) - if not safe_commit('add_project_cost', {'project_id': project_id}): - flash(_('Could not add cost due to a database error. Please check server logs.'), 'error') - return render_template('projects/add_cost.html', project=project) - - flash(_('Cost added successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('projects/add_cost.html', project=project) + if not safe_commit("add_project_cost", {"project_id": project_id}): + flash(_("Could not add cost due to a database error. Please check server logs."), "error") + return render_template("projects/add_cost.html", project=project) + + flash(_("Cost added successfully"), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + return render_template("projects/add_cost.html", project=project) -@projects_bp.route('/projects//costs//edit', methods=['GET', 'POST']) + +@projects_bp.route("/projects//costs//edit", methods=["GET", "POST"]) @login_required def edit_cost(project_id, cost_id): """Edit a project cost""" project = Project.query.get_or_404(project_id) cost = ProjectCost.query.get_or_404(cost_id) - + # Verify cost belongs to project if cost.project_id != project_id: - flash(_('Cost not found'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("Cost not found"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + # Only admin or the user who created the cost can edit if not current_user.is_admin and cost.user_id != current_user.id: - flash(_('You do not have permission to edit this cost'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - if request.method == 'POST': - description = request.form.get('description', '').strip() - category = request.form.get('category', '').strip() - amount = request.form.get('amount', '').strip() - cost_date_str = request.form.get('cost_date', '').strip() - billable = request.form.get('billable') == 'on' - notes = request.form.get('notes', '').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - + flash(_("You do not have permission to edit this cost"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + if request.method == "POST": + description = request.form.get("description", "").strip() + category = request.form.get("category", "").strip() + amount = request.form.get("amount", "").strip() + cost_date_str = request.form.get("cost_date", "").strip() + billable = request.form.get("billable") == "on" + notes = request.form.get("notes", "").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + # Validate required fields if not description or not category or not amount or not cost_date_str: - flash(_('Description, category, amount, and date are required'), 'error') - return render_template('projects/edit_cost.html', project=project, cost=cost) - + flash(_("Description, category, amount, and date are required"), "error") + return render_template("projects/edit_cost.html", project=project, cost=cost) + # Validate amount try: amount = Decimal(amount) if amount <= 0: - raise ValueError('Amount must be positive') + raise ValueError("Amount must be positive") except (ValueError, Exception): - flash(_('Invalid amount format'), 'error') - return render_template('projects/edit_cost.html', project=project, cost=cost) - + flash(_("Invalid amount format"), "error") + return render_template("projects/edit_cost.html", project=project, cost=cost) + # Validate date try: - cost_date = datetime.strptime(cost_date_str, '%Y-%m-%d').date() + cost_date = datetime.strptime(cost_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return render_template('projects/edit_cost.html', project=project, cost=cost) - + flash(_("Invalid date format"), "error") + return render_template("projects/edit_cost.html", project=project, cost=cost) + # Update cost cost.description = description cost.category = category @@ -1251,153 +1300,156 @@ def edit_cost(project_id, cost_id): cost.notes = notes cost.currency_code = currency_code cost.updated_at = datetime.utcnow() - - if not safe_commit('edit_project_cost', {'cost_id': cost_id}): - flash(_('Could not update cost due to a database error. Please check server logs.'), 'error') - return render_template('projects/edit_cost.html', project=project, cost=cost) - - flash(_('Cost updated successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('projects/edit_cost.html', project=project, cost=cost) - - -@projects_bp.route('/projects//costs//delete', methods=['POST']) + + if not safe_commit("edit_project_cost", {"cost_id": cost_id}): + flash(_("Could not update cost due to a database error. Please check server logs."), "error") + return render_template("projects/edit_cost.html", project=project, cost=cost) + + flash(_("Cost updated successfully"), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + + return render_template("projects/edit_cost.html", project=project, cost=cost) + + +@projects_bp.route("/projects//costs//delete", methods=["POST"]) @login_required def delete_cost(project_id, cost_id): """Delete a project cost""" project = Project.query.get_or_404(project_id) cost = ProjectCost.query.get_or_404(cost_id) - + # Verify cost belongs to project if cost.project_id != project_id: - flash(_('Cost not found'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("Cost not found"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + # Only admin or the user who created the cost can delete if not current_user.is_admin and cost.user_id != current_user.id: - flash(_('You do not have permission to delete this cost'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("You do not have permission to delete this cost"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + # Check if cost has been invoiced if cost.is_invoiced: - flash(_('Cannot delete cost that has been invoiced'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("Cannot delete cost that has been invoiced"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + cost_description = cost.description db.session.delete(cost) - if not safe_commit('delete_project_cost', {'cost_id': cost_id}): - flash(_('Could not delete cost due to a database error. Please check server logs.'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - flash(_(f'Cost "{cost_description}" deleted successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) + if not safe_commit("delete_project_cost", {"cost_id": cost_id}): + flash(_("Could not delete cost due to a database error. Please check server logs."), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + flash(_(f'Cost "{cost_description}" deleted successfully'), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) # API endpoint for getting project costs as JSON -@projects_bp.route('/api/projects//costs') +@projects_bp.route("/api/projects//costs") @login_required def api_project_costs(project_id): """API endpoint to get project costs""" project = Project.query.get_or_404(project_id) - - start_date_str = request.args.get('start_date') - end_date_str = request.args.get('end_date') - + + start_date_str = request.args.get("start_date") + end_date_str = request.args.get("end_date") + start_date = None end_date = None - + if start_date_str: try: - start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() + start_date = datetime.strptime(start_date_str, "%Y-%m-%d").date() except ValueError: pass - + if end_date_str: try: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() except ValueError: pass - + costs = ProjectCost.get_project_costs(project_id, start_date, end_date) total_costs = ProjectCost.get_total_costs(project_id, start_date, end_date) billable_costs = ProjectCost.get_total_costs(project_id, start_date, end_date, billable_only=True) - - return jsonify({ - 'costs': [cost.to_dict() for cost in costs], - 'total_costs': total_costs, - 'billable_costs': billable_costs, - 'count': len(costs) - }) + + return jsonify( + { + "costs": [cost.to_dict() for cost in costs], + "total_costs": total_costs, + "billable_costs": billable_costs, + "count": len(costs), + } + ) # ===== PROJECT EXTRA GOODS ROUTES ===== -@projects_bp.route('/projects//goods') + +@projects_bp.route("/projects//goods") @login_required def list_goods(project_id): """List all extra goods for a project""" project = Project.query.get_or_404(project_id) - + # Get goods goods = project.extra_goods.order_by(ExtraGood.created_at.desc()).all() - + # Get category breakdown category_breakdown = ExtraGood.get_goods_by_category(project_id=project_id) - + # Calculate totals total_amount = ExtraGood.get_total_amount(project_id=project_id) billable_amount = ExtraGood.get_total_amount(project_id=project_id, billable_only=True) - + return render_template( - 'projects/goods.html', + "projects/goods.html", project=project, goods=goods, category_breakdown=category_breakdown, total_amount=total_amount, - billable_amount=billable_amount + billable_amount=billable_amount, ) -@projects_bp.route('/projects//goods/add', methods=['GET', 'POST']) +@projects_bp.route("/projects//goods/add", methods=["GET", "POST"]) @login_required def add_good(project_id): """Add a new extra good to a project""" project = Project.query.get_or_404(project_id) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - category = request.form.get('category', 'product').strip() - quantity = request.form.get('quantity', '1').strip() - unit_price = request.form.get('unit_price', '').strip() - sku = request.form.get('sku', '').strip() - billable = request.form.get('billable') == 'on' - currency_code = request.form.get('currency_code', 'EUR').strip() - + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + category = request.form.get("category", "product").strip() + quantity = request.form.get("quantity", "1").strip() + unit_price = request.form.get("unit_price", "").strip() + sku = request.form.get("sku", "").strip() + billable = request.form.get("billable") == "on" + currency_code = request.form.get("currency_code", "EUR").strip() + # Validate required fields if not name or not unit_price: - flash(_('Name and unit price are required'), 'error') - return render_template('projects/add_good.html', project=project) - + flash(_("Name and unit price are required"), "error") + return render_template("projects/add_good.html", project=project) + # Validate quantity try: quantity = Decimal(quantity) if quantity <= 0: - raise ValueError('Quantity must be positive') + raise ValueError("Quantity must be positive") except (ValueError, Exception): - flash(_('Invalid quantity format'), 'error') - return render_template('projects/add_good.html', project=project) - + flash(_("Invalid quantity format"), "error") + return render_template("projects/add_good.html", project=project) + # Validate unit price try: unit_price = Decimal(unit_price) if unit_price < 0: - raise ValueError('Unit price cannot be negative') + raise ValueError("Unit price cannot be negative") except (ValueError, Exception): - flash(_('Invalid unit price format'), 'error') - return render_template('projects/add_good.html', project=project) - + flash(_("Invalid unit price format"), "error") + return render_template("projects/add_good.html", project=project) + # Create extra good good = ExtraGood( name=name, @@ -1409,70 +1461,70 @@ def add_good(project_id): billable=billable, currency_code=currency_code, project_id=project_id, - created_by=current_user.id + created_by=current_user.id, ) - + db.session.add(good) - if not safe_commit('add_project_good', {'project_id': project_id}): - flash(_('Could not add extra good due to a database error. Please check server logs.'), 'error') - return render_template('projects/add_good.html', project=project) - - flash(_('Extra good added successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('projects/add_good.html', project=project) + if not safe_commit("add_project_good", {"project_id": project_id}): + flash(_("Could not add extra good due to a database error. Please check server logs."), "error") + return render_template("projects/add_good.html", project=project) + + flash(_("Extra good added successfully"), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + return render_template("projects/add_good.html", project=project) -@projects_bp.route('/projects//goods//edit', methods=['GET', 'POST']) + +@projects_bp.route("/projects//goods//edit", methods=["GET", "POST"]) @login_required def edit_good(project_id, good_id): """Edit a project extra good""" project = Project.query.get_or_404(project_id) good = ExtraGood.query.get_or_404(good_id) - + # Verify good belongs to project if good.project_id != project_id: - flash(_('Extra good not found'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("Extra good not found"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + # Only admin or the user who created the good can edit if not current_user.is_admin and good.created_by != current_user.id: - flash(_('You do not have permission to edit this extra good'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - category = request.form.get('category', 'product').strip() - quantity = request.form.get('quantity', '1').strip() - unit_price = request.form.get('unit_price', '').strip() - sku = request.form.get('sku', '').strip() - billable = request.form.get('billable') == 'on' - currency_code = request.form.get('currency_code', 'EUR').strip() - + flash(_("You do not have permission to edit this extra good"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + category = request.form.get("category", "product").strip() + quantity = request.form.get("quantity", "1").strip() + unit_price = request.form.get("unit_price", "").strip() + sku = request.form.get("sku", "").strip() + billable = request.form.get("billable") == "on" + currency_code = request.form.get("currency_code", "EUR").strip() + # Validate required fields if not name or not unit_price: - flash(_('Name and unit price are required'), 'error') - return render_template('projects/edit_good.html', project=project, good=good) - + flash(_("Name and unit price are required"), "error") + return render_template("projects/edit_good.html", project=project, good=good) + # Validate quantity try: quantity = Decimal(quantity) if quantity <= 0: - raise ValueError('Quantity must be positive') + raise ValueError("Quantity must be positive") except (ValueError, Exception): - flash(_('Invalid quantity format'), 'error') - return render_template('projects/edit_good.html', project=project, good=good) - + flash(_("Invalid quantity format"), "error") + return render_template("projects/edit_good.html", project=project, good=good) + # Validate unit price try: unit_price = Decimal(unit_price) if unit_price < 0: - raise ValueError('Unit price cannot be negative') + raise ValueError("Unit price cannot be negative") except (ValueError, Exception): - flash(_('Invalid unit price format'), 'error') - return render_template('projects/edit_good.html', project=project, good=good) - + flash(_("Invalid unit price format"), "error") + return render_template("projects/edit_good.html", project=project, good=good) + # Update good good.name = name good.description = description if description else None @@ -1483,65 +1535,65 @@ def edit_good(project_id, good_id): good.billable = billable good.currency_code = currency_code good.update_total() - - if not safe_commit('edit_project_good', {'good_id': good_id}): - flash(_('Could not update extra good due to a database error. Please check server logs.'), 'error') - return render_template('projects/edit_good.html', project=project, good=good) - - flash(_('Extra good updated successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('projects/edit_good.html', project=project, good=good) - - -@projects_bp.route('/projects//goods//delete', methods=['POST']) + + if not safe_commit("edit_project_good", {"good_id": good_id}): + flash(_("Could not update extra good due to a database error. Please check server logs."), "error") + return render_template("projects/edit_good.html", project=project, good=good) + + flash(_("Extra good updated successfully"), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + + return render_template("projects/edit_good.html", project=project, good=good) + + +@projects_bp.route("/projects//goods//delete", methods=["POST"]) @login_required def delete_good(project_id, good_id): """Delete a project extra good""" project = Project.query.get_or_404(project_id) good = ExtraGood.query.get_or_404(good_id) - + # Verify good belongs to project if good.project_id != project_id: - flash(_('Extra good not found'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("Extra good not found"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + # Only admin or the user who created the good can delete if not current_user.is_admin and good.created_by != current_user.id: - flash(_('You do not have permission to delete this extra good'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("You do not have permission to delete this extra good"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + # Check if good has been added to an invoice if good.invoice_id: - flash(_('Cannot delete extra good that has been added to an invoice'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - + flash(_("Cannot delete extra good that has been added to an invoice"), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + good_name = good.name db.session.delete(good) - if not safe_commit('delete_project_good', {'good_id': good_id}): - flash(_('Could not delete extra good due to a database error. Please check server logs.'), 'error') - return redirect(url_for('projects.view_project', project_id=project_id)) - - flash(_(f'Extra good "{good_name}" deleted successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) + if not safe_commit("delete_project_good", {"good_id": good_id}): + flash(_("Could not delete extra good due to a database error. Please check server logs."), "error") + return redirect(url_for("projects.view_project", project_id=project_id)) + + flash(_(f'Extra good "{good_name}" deleted successfully'), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) # API endpoint for getting project extra goods as JSON -@projects_bp.route('/api/projects//goods') +@projects_bp.route("/api/projects//goods") @login_required def api_project_goods(project_id): """API endpoint to get project extra goods""" project = Project.query.get_or_404(project_id) - + goods = ExtraGood.get_project_goods(project_id) total_amount = ExtraGood.get_total_amount(project_id=project_id) billable_amount = ExtraGood.get_total_amount(project_id=project_id, billable_only=True) - - return jsonify({ - 'goods': [good.to_dict() for good in goods], - 'total_amount': total_amount, - 'billable_amount': billable_amount, - 'count': len(goods) - }) - + return jsonify( + { + "goods": [good.to_dict() for good in goods], + "total_amount": total_amount, + "billable_amount": billable_amount, + "count": len(goods), + } + ) diff --git a/app/routes/projects_refactored_example.py b/app/routes/projects_refactored_example.py index 9ce82f81..2c7c5504 100644 --- a/app/routes/projects_refactored_example.py +++ b/app/routes/projects_refactored_example.py @@ -15,155 +15,142 @@ from app.models import Project, Client, UserFavoriteProject from app.utils.permissions import admin_or_permission_required -projects_bp = Blueprint('projects', __name__) +projects_bp = Blueprint("projects", __name__) -@projects_bp.route('/projects') +@projects_bp.route("/projects") @login_required def list_projects(): """ List all projects - REFACTORED VERSION - + This version fixes N+1 queries by using joinedload to eagerly load related data (clients) in a single query. """ from app import track_page_view + track_page_view("projects_list") - - page = request.args.get('page', 1, type=int) - status = request.args.get('status', 'active') - client_name = request.args.get('client', '').strip() - search = request.args.get('search', '').strip() - favorites_only = request.args.get('favorites', '').lower() == 'true' - + + page = request.args.get("page", 1, type=int) + status = request.args.get("status", "active") + client_name = request.args.get("client", "").strip() + search = request.args.get("search", "").strip() + favorites_only = request.args.get("favorites", "").lower() == "true" + # Use repository with eager loading to fix N+1 queries project_repo = ProjectRepository() - query = project_repo.query().options( - joinedload(Project.client) # Eagerly load client to avoid N+1 - ) - + query = project_repo.query().options(joinedload(Project.client)) # Eagerly load client to avoid N+1 + # Filter by favorites if requested if favorites_only: query = query.join( UserFavoriteProject, - db.and_( - UserFavoriteProject.project_id == Project.id, - UserFavoriteProject.user_id == current_user.id - ) + db.and_(UserFavoriteProject.project_id == Project.id, UserFavoriteProject.user_id == current_user.id), ) - + # Filter by status - if status == 'active': - query = query.filter(Project.status == 'active') - elif status == 'archived': - query = query.filter(Project.status == 'archived') - elif status == 'inactive': - query = query.filter(Project.status == 'inactive') - + if status == "active": + query = query.filter(Project.status == "active") + elif status == "archived": + query = query.filter(Project.status == "archived") + elif status == "inactive": + query = query.filter(Project.status == "inactive") + # Filter by client if client_name: query = query.join(Client).filter(Client.name == client_name) - + # Search filter if search: like = f"%{search}%" - query = query.filter( - db.or_( - Project.name.ilike(like), - Project.description.ilike(like) - ) - ) - + query = query.filter(db.or_(Project.name.ilike(like), Project.description.ilike(like))) + # Paginate with eager loading - projects_pagination = query.order_by(Project.name).paginate( - page=page, - per_page=20, - error_out=False - ) - + projects_pagination = query.order_by(Project.name).paginate(page=page, per_page=20, error_out=False) + # Get user's favorite project IDs (single query) favorite_project_ids = { - fav.project_id - for fav in UserFavoriteProject.query.filter_by(user_id=current_user.id).all() + fav.project_id for fav in UserFavoriteProject.query.filter_by(user_id=current_user.id).all() } - + # Get clients for filter dropdown (single query) client_repo = ClientRepository() clients = client_repo.get_active_clients() client_list = [c.name for c in clients] - + return render_template( - 'projects/list.html', + "projects/list.html", projects=projects_pagination.items, status=status, clients=client_list, favorite_project_ids=favorite_project_ids, favorites_only=favorites_only, - pagination=projects_pagination + pagination=projects_pagination, ) -@projects_bp.route('/projects/') +@projects_bp.route("/projects/") @login_required def view_project(project_id): """ View project details - REFACTORED VERSION - + This version uses the service layer and fixes N+1 queries. """ from app.repositories import TimeEntryRepository from app.models import Task, Comment, ProjectCost, KanbanColumn from sqlalchemy.orm import joinedload - + # Use repository to get project with relations project_repo = ProjectRepository() project = project_repo.get_with_stats(project_id) - + if not project: - flash(_('Project not found'), 'error') - return redirect(url_for('projects.list_projects')) - + flash(_("Project not found"), "error") + return redirect(url_for("projects.list_projects")) + # Get time entries with eager loading (fixes N+1) time_entry_repo = TimeEntryRepository() - page = request.args.get('page', 1, type=int) - - entries_query = time_entry_repo.query().filter( - TimeEntry.project_id == project_id, - TimeEntry.end_time.isnot(None) - ).options( - joinedload(TimeEntry.user), # Eagerly load user - joinedload(TimeEntry.task) # Eagerly load task - ).order_by(TimeEntry.start_time.desc()) - - entries_pagination = entries_query.paginate( - page=page, - per_page=50, - error_out=False + page = request.args.get("page", 1, type=int) + + entries_query = ( + time_entry_repo.query() + .filter(TimeEntry.project_id == project_id, TimeEntry.end_time.isnot(None)) + .options(joinedload(TimeEntry.user), joinedload(TimeEntry.task)) # Eagerly load user # Eagerly load task + .order_by(TimeEntry.start_time.desc()) ) - + + entries_pagination = entries_query.paginate(page=page, per_page=50, error_out=False) + # Get tasks with eager loading - tasks = Task.query.filter_by(project_id=project_id).options( - joinedload(Task.assignee) # If Task has assignee relationship - ).order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() - + tasks = ( + Task.query.filter_by(project_id=project_id) + .options(joinedload(Task.assignee)) # If Task has assignee relationship + .order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()) + .all() + ) + # Get user totals (this might need optimization too) user_totals = project.get_user_totals() - + # Get comments with eager loading - comments = Comment.query.filter_by(project_id=project_id).options( - joinedload(Comment.author) # Eagerly load author - ).order_by(Comment.created_at.desc()).all() - + comments = ( + Comment.query.filter_by(project_id=project_id) + .options(joinedload(Comment.author)) # Eagerly load author + .order_by(Comment.created_at.desc()) + .all() + ) + # Get recent project costs - recent_costs = ProjectCost.query.filter_by(project_id=project_id).order_by( - ProjectCost.cost_date.desc() - ).limit(5).all() - + recent_costs = ( + ProjectCost.query.filter_by(project_id=project_id).order_by(ProjectCost.cost_date.desc()).limit(5).all() + ) + # Get kanban columns kanban_columns = KanbanColumn.get_active_columns(project_id=project_id) if KanbanColumn else [] - + return render_template( - 'projects/view.html', + "projects/view.html", project=project, entries=entries_pagination.items, entries_pagination=entries_pagination, @@ -171,39 +158,38 @@ def view_project(project_id): user_totals=user_totals, comments=comments, recent_costs=recent_costs, - kanban_columns=kanban_columns + kanban_columns=kanban_columns, ) -@projects_bp.route('/projects/create', methods=['GET', 'POST']) +@projects_bp.route("/projects/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_projects') +@admin_or_permission_required("create_projects") def create_project(): """ Create a new project - REFACTORED VERSION using service layer """ - if request.method == 'POST': + if request.method == "POST": # Use service layer for business logic project_service = ProjectService() - + result = project_service.create_project( - name=request.form.get('name', '').strip(), - client_id=request.form.get('client_id', type=int), - description=request.form.get('description', '').strip() or None, - billable=request.form.get('billable') == 'on', - hourly_rate=request.form.get('hourly_rate', type=float), - created_by=current_user.id + name=request.form.get("name", "").strip(), + client_id=request.form.get("client_id", type=int), + description=request.form.get("description", "").strip() or None, + billable=request.form.get("billable") == "on", + hourly_rate=request.form.get("hourly_rate", type=float), + created_by=current_user.id, ) - - if result['success']: - flash(_('Project created successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=result['project'].id)) + + if result["success"]: + flash(_("Project created successfully"), "success") + return redirect(url_for("projects.view_project", project_id=result["project"].id)) else: - flash(_(result['message']), 'error') - + flash(_(result["message"]), "error") + # GET request - show form client_repo = ClientRepository() clients = client_repo.get_active_clients() - - return render_template('projects/create.html', clients=clients) + return render_template("projects/create.html", clients=clients) diff --git a/app/routes/push_notifications.py b/app/routes/push_notifications.py index 47988cf5..939679ea 100644 --- a/app/routes/push_notifications.py +++ b/app/routes/push_notifications.py @@ -10,55 +10,54 @@ from app.utils.db import safe_commit import json -push_bp = Blueprint('push', __name__) +push_bp = Blueprint("push", __name__) -@push_bp.route('/api/push/subscribe', methods=['POST']) +@push_bp.route("/api/push/subscribe", methods=["POST"]) @login_required def subscribe_push(): """Subscribe user to push notifications.""" try: subscription = request.json - + # Store subscription in user model or separate table # For now, store in user's settings/preferences - if not hasattr(current_user, 'push_subscription'): + if not hasattr(current_user, "push_subscription"): # Add push_subscription field to User model if needed pass - + # Store subscription (could be in a separate PushSubscription model) # For simplicity, storing as JSON in user preferences - user_prefs = getattr(current_user, 'preferences', {}) or {} + user_prefs = getattr(current_user, "preferences", {}) or {} if not isinstance(user_prefs, dict): user_prefs = {} - - user_prefs['push_subscription'] = subscription + + user_prefs["push_subscription"] = subscription current_user.preferences = user_prefs - - if safe_commit('subscribe_push', {'user_id': current_user.id}): - return jsonify({'success': True, 'message': 'Subscribed to push notifications'}) + + if safe_commit("subscribe_push", {"user_id": current_user.id}): + return jsonify({"success": True, "message": "Subscribed to push notifications"}) else: - return jsonify({'success': False, 'message': 'Failed to save subscription'}), 500 - + return jsonify({"success": False, "message": "Failed to save subscription"}), 500 + except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 + return jsonify({"success": False, "message": str(e)}), 500 -@push_bp.route('/api/push/unsubscribe', methods=['POST']) +@push_bp.route("/api/push/unsubscribe", methods=["POST"]) @login_required def unsubscribe_push(): """Unsubscribe user from push notifications.""" try: - user_prefs = getattr(current_user, 'preferences', {}) or {} + user_prefs = getattr(current_user, "preferences", {}) or {} if isinstance(user_prefs, dict): - user_prefs.pop('push_subscription', None) + user_prefs.pop("push_subscription", None) current_user.preferences = user_prefs - - if safe_commit('unsubscribe_push', {'user_id': current_user.id}): - return jsonify({'success': True, 'message': 'Unsubscribed from push notifications'}) - - return jsonify({'success': False, 'message': 'No subscription found'}), 404 - - except Exception as e: - return jsonify({'success': False, 'message': str(e)}), 500 + if safe_commit("unsubscribe_push", {"user_id": current_user.id}): + return jsonify({"success": True, "message": "Unsubscribed from push notifications"}) + + return jsonify({"success": False, "message": "No subscription found"}), 404 + + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 diff --git a/app/routes/quotes.py b/app/routes/quotes.py index eeffaee4..85b46381 100644 --- a/app/routes/quotes.py +++ b/app/routes/quotes.py @@ -8,25 +8,26 @@ from app.utils.db import safe_commit from app.utils.permissions import admin_or_permission_required, permission_required -quotes_bp = Blueprint('quotes', __name__) +quotes_bp = Blueprint("quotes", __name__) -@quotes_bp.route('/quotes') + +@quotes_bp.route("/quotes") @login_required def list_quotes(): """List all quotes with optional analytics""" - status = request.args.get('status', 'all') - search = request.args.get('search', '').strip() - show_analytics = request.args.get('analytics', 'false').lower() == 'true' - + status = request.args.get("status", "all") + search = request.args.get("search", "").strip() + show_analytics = request.args.get("analytics", "false").lower() == "true" + query = Quote.query - + # Filter by user unless admin if not current_user.is_admin: query = query.filter_by(created_by=current_user.id) - - if status != 'all': + + if status != "all": query = query.filter_by(status=status) - + if search: like = f"%{search}%" query = query.join(Client).filter( @@ -34,175 +35,189 @@ def list_quotes(): Quote.title.ilike(like), Quote.quote_number.ilike(like), Quote.description.ilike(like), - Client.name.ilike(like) + Client.name.ilike(like), ) ) - + quotes = query.order_by(Quote.created_at.desc()).all() - + # Calculate analytics if requested analytics = None if show_analytics: from datetime import timedelta from app.utils.timezone import local_now from sqlalchemy import func - + # Base query for analytics analytics_query = Quote.query if not current_user.is_admin: analytics_query = analytics_query.filter_by(created_by=current_user.id) - + # Total quotes total_quotes = analytics_query.count() - + # Quotes by status quotes_by_status = {} - for status_val in ['draft', 'sent', 'accepted', 'rejected', 'expired']: + for status_val in ["draft", "sent", "accepted", "rejected", "expired"]: count = analytics_query.filter_by(status=status_val).count() quotes_by_status[status_val] = count - + # Total quote value total_value = analytics_query.with_entities(func.sum(Quote.total_amount)).scalar() or 0 - + # Accepted quotes value - accepted_value = analytics_query.filter_by(status='accepted').with_entities(func.sum(Quote.total_amount)).scalar() or 0 - + accepted_value = ( + analytics_query.filter_by(status="accepted").with_entities(func.sum(Quote.total_amount)).scalar() or 0 + ) + # Acceptance rate - sent_count = quotes_by_status.get('sent', 0) - accepted_count = quotes_by_status.get('accepted', 0) + sent_count = quotes_by_status.get("sent", 0) + accepted_count = quotes_by_status.get("accepted", 0) acceptance_rate = (accepted_count / sent_count * 100) if sent_count > 0 else 0 - + # Average quote value avg_value = (total_value / total_quotes) if total_quotes > 0 else 0 - + # Quotes in last 30 days thirty_days_ago = local_now() - timedelta(days=30) recent_quotes = analytics_query.filter(Quote.created_at >= thirty_days_ago).count() - + # Quotes by client (top 10) - quotes_by_client = db.session.query( - Client.name, - func.count(Quote.id).label('count'), - func.sum(Quote.total_amount).label('total') - ).join(Quote).group_by(Client.id, Client.name) + quotes_by_client = ( + db.session.query( + Client.name, func.count(Quote.id).label("count"), func.sum(Quote.total_amount).label("total") + ) + .join(Quote) + .group_by(Client.id, Client.name) + ) if not current_user.is_admin: quotes_by_client = quotes_by_client.filter(Quote.created_by == current_user.id) quotes_by_client = quotes_by_client.order_by(func.count(Quote.id).desc()).limit(10).all() - + analytics = { - 'total_quotes': total_quotes, - 'quotes_by_status': quotes_by_status, - 'total_value': float(total_value), - 'accepted_value': float(accepted_value), - 'acceptance_rate': round(acceptance_rate, 1), - 'avg_value': float(avg_value), - 'recent_quotes': recent_quotes, - 'quotes_by_client': [{'name': name, 'count': count, 'total': float(total)} for name, count, total in quotes_by_client] + "total_quotes": total_quotes, + "quotes_by_status": quotes_by_status, + "total_value": float(total_value), + "accepted_value": float(accepted_value), + "acceptance_rate": round(acceptance_rate, 1), + "avg_value": float(avg_value), + "recent_quotes": recent_quotes, + "quotes_by_client": [ + {"name": name, "count": count, "total": float(total)} for name, count, total in quotes_by_client + ], } - - return render_template('quotes/list.html', quotes=quotes, status=status, search=search, analytics=analytics, show_analytics=show_analytics) -@quotes_bp.route('/quotes/create', methods=['GET', 'POST']) + return render_template( + "quotes/list.html", + quotes=quotes, + status=status, + search=search, + analytics=analytics, + show_analytics=show_analytics, + ) + + +@quotes_bp.route("/quotes/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_quotes') +@admin_or_permission_required("create_quotes") def create_quote(): """Create a new quote""" - if request.method == 'POST': - client_id = request.form.get('client_id', '').strip() - title = request.form.get('title', '').strip() - description = request.form.get('description', '').strip() - total_amount = request.form.get('total_amount', '').strip() - hourly_rate = request.form.get('hourly_rate', '').strip() - estimated_hours = request.form.get('estimated_hours', '').strip() - tax_rate = request.form.get('tax_rate', '0').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - valid_until = request.form.get('valid_until', '').strip() - notes = request.form.get('notes', '').strip() - terms = request.form.get('terms', '').strip() - + if request.method == "POST": + client_id = request.form.get("client_id", "").strip() + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + total_amount = request.form.get("total_amount", "").strip() + hourly_rate = request.form.get("hourly_rate", "").strip() + estimated_hours = request.form.get("estimated_hours", "").strip() + tax_rate = request.form.get("tax_rate", "0").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + valid_until = request.form.get("valid_until", "").strip() + notes = request.form.get("notes", "").strip() + terms = request.form.get("terms", "").strip() + try: current_app.logger.info( "POST /quotes/create user=%s title=%s client_id=%s", current_user.username, - title or '', - client_id or '' + title or "", + client_id or "", ) except Exception: pass - + # Validate required fields if not title or not client_id: - flash(_('Quote title and client are required'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Quote title and client are required"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Get client and validate client = Client.query.get(client_id) if not client: - flash(_('Selected client not found'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Selected client not found"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Validate amounts try: total_amount = Decimal(total_amount) if total_amount else None if total_amount is not None and total_amount < 0: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid total amount format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid total amount format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + try: hourly_rate = Decimal(hourly_rate) if hourly_rate else None if hourly_rate is not None and hourly_rate < 0: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid hourly rate format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid hourly rate format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + try: estimated_hours = float(estimated_hours) if estimated_hours else None if estimated_hours is not None and estimated_hours < 0: raise ValueError except ValueError: - flash(_('Invalid estimated hours format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid estimated hours format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + try: - tax_rate = Decimal(tax_rate) if tax_rate else Decimal('0') + tax_rate = Decimal(tax_rate) if tax_rate else Decimal("0") if tax_rate < 0 or tax_rate > 100: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid tax rate format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid tax rate format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Validate discount fields discount_amount_decimal = None if discount_type and discount_amount: try: discount_amount_decimal = Decimal(discount_amount) - if discount_type == 'percentage': + if discount_type == "percentage": if discount_amount_decimal < 0 or discount_amount_decimal > 100: raise InvalidOperation - elif discount_type == 'fixed': + elif discount_type == "fixed": if discount_amount_decimal < 0: raise InvalidOperation else: discount_type = None # Invalid type, ignore discount except (InvalidOperation, ValueError): - flash(_('Invalid discount amount format'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid discount amount format"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Parse valid_until date valid_until_date = None if valid_until: try: - valid_until_date = datetime.strptime(valid_until, '%Y-%m-%d').date() + valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format for valid until'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + flash(_("Invalid date format for valid until"), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Generate quote number quote_number = Quote.generate_quote_number() - + # Create quote quote = Quote( quote_number=quote_number, @@ -219,63 +234,60 @@ def create_quote(): discount_type=discount_type if discount_type else None, discount_amount=discount_amount_decimal if discount_amount_decimal else None, discount_reason=discount_reason if discount_reason else None, - coupon_code=coupon_code.upper() if coupon_code else None + coupon_code=coupon_code.upper() if coupon_code else None, ) - + db.session.add(quote) db.session.flush() # Get quote ID for items - + # Process line items if provided - item_descriptions = request.form.getlist('item_description[]') - item_quantities = request.form.getlist('item_quantity[]') - item_prices = request.form.getlist('item_price[]') - item_units = request.form.getlist('item_unit[]') - item_stock_ids = request.form.getlist('item_stock_item_id[]') - item_warehouse_ids = request.form.getlist('item_warehouse_id[]') - - for desc, qty, price, unit, stock_id, wh_id in zip(item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids): + item_descriptions = request.form.getlist("item_description[]") + item_quantities = request.form.getlist("item_quantity[]") + item_prices = request.form.getlist("item_price[]") + item_units = request.form.getlist("item_unit[]") + item_stock_ids = request.form.getlist("item_stock_item_id[]") + item_warehouse_ids = request.form.getlist("item_warehouse_id[]") + + for desc, qty, price, unit, stock_id, wh_id in zip( + item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids + ): if desc.strip(): try: stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None - + item = QuoteItem( quote_id=quote.id, description=desc.strip(), - quantity=Decimal(qty) if qty else Decimal('1'), - unit_price=Decimal(price) if price else Decimal('0'), + quantity=Decimal(qty) if qty else Decimal("1"), + unit_price=Decimal(price) if price else Decimal("0"), unit=unit.strip() if unit else None, stock_item_id=stock_item_id, - warehouse_id=warehouse_id + warehouse_id=warehouse_id, ) db.session.add(item) except (ValueError, InvalidOperation): pass # Skip invalid items - + quote.calculate_totals() - - if not safe_commit('create_quote', {'title': title, 'client_id': client_id}): - flash(_('Could not create quote due to a database error. Please check server logs.'), 'error') - return render_template('quotes/create.html', clients=Client.get_active_clients()) - + + if not safe_commit("create_quote", {"title": title, "client_id": client_id}): + flash(_("Could not create quote due to a database error. Please check server logs."), "error") + return render_template("quotes/create.html", clients=Client.get_active_clients()) + # Log event - log_event("quote.created", - user_id=current_user.id, - quote_id=quote.id, - quote_title=title, - client_id=client_id) - track_event(current_user.id, "quote.created", { - "quote_id": quote.id, - "quote_title": title, - "client_id": client_id - }) - - flash(_('Quote created successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote.id)) - - return render_template('quotes/create.html', clients=Client.get_active_clients()) - -@quotes_bp.route('/quotes/') + log_event("quote.created", user_id=current_user.id, quote_id=quote.id, quote_title=title, client_id=client_id) + track_event( + current_user.id, "quote.created", {"quote_id": quote.id, "quote_title": title, "client_id": client_id} + ) + + flash(_("Quote created successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote.id)) + + return render_template("quotes/create.html", clients=Client.get_active_clients()) + + +@quotes_bp.route("/quotes/") @login_required def view_quote(quote_id): """View quote details""" @@ -283,73 +295,75 @@ def view_quote(quote_id): quote.calculate_totals() # Ensure totals are up to date # Get all comments (both internal and client-facing) from app.models import Comment + comments = Comment.get_quote_comments(quote_id, include_replies=True, include_internal=True) - - return render_template('quotes/view.html', quote=quote, comments=comments) -@quotes_bp.route('/quotes//edit', methods=['GET', 'POST']) + return render_template("quotes/view.html", quote=quote, comments=comments) + + +@quotes_bp.route("/quotes//edit", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def edit_quote(quote_id): """Edit an quote""" quote = Quote.query.get_or_404(quote_id) - + # Only allow editing draft quotes - if quote.status != 'draft': - flash(_('Only draft quotes can be edited'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if request.method == 'POST': - title = request.form.get('title', '').strip() - description = request.form.get('description', '').strip() - tax_rate = request.form.get('tax_rate', '0').strip() - currency_code = request.form.get('currency_code', 'EUR').strip() - valid_until = request.form.get('valid_until', '').strip() - notes = request.form.get('notes', '').strip() - terms = request.form.get('terms', '').strip() - payment_terms = request.form.get('payment_terms', '').strip() - visible_to_client = request.form.get('visible_to_client') == 'on' - + if quote.status != "draft": + flash(_("Only draft quotes can be edited"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if request.method == "POST": + title = request.form.get("title", "").strip() + description = request.form.get("description", "").strip() + tax_rate = request.form.get("tax_rate", "0").strip() + currency_code = request.form.get("currency_code", "EUR").strip() + valid_until = request.form.get("valid_until", "").strip() + notes = request.form.get("notes", "").strip() + terms = request.form.get("terms", "").strip() + payment_terms = request.form.get("payment_terms", "").strip() + visible_to_client = request.form.get("visible_to_client") == "on" + # Discount fields - discount_type = request.form.get('discount_type', '').strip() - discount_amount = request.form.get('discount_amount', '').strip() - discount_reason = request.form.get('discount_reason', '').strip() - coupon_code = request.form.get('coupon_code', '').strip() - + discount_type = request.form.get("discount_type", "").strip() + discount_amount = request.form.get("discount_amount", "").strip() + discount_reason = request.form.get("discount_reason", "").strip() + coupon_code = request.form.get("coupon_code", "").strip() + try: - tax_rate = Decimal(tax_rate) if tax_rate else Decimal('0') + tax_rate = Decimal(tax_rate) if tax_rate else Decimal("0") if tax_rate < 0 or tax_rate > 100: raise InvalidOperation except (InvalidOperation, ValueError): - flash(_('Invalid tax rate format'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid tax rate format"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + # Validate discount fields discount_amount_decimal = None if discount_type and discount_amount: try: discount_amount_decimal = Decimal(discount_amount) - if discount_type == 'percentage': + if discount_type == "percentage": if discount_amount_decimal < 0 or discount_amount_decimal > 100: raise InvalidOperation - elif discount_type == 'fixed': + elif discount_type == "fixed": if discount_amount_decimal < 0: raise InvalidOperation else: discount_type = None # Invalid type, ignore discount except (InvalidOperation, ValueError): - flash(_('Invalid discount amount format'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid discount amount format"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + # Parse valid_until date valid_until_date = None if valid_until: try: - valid_until_date = datetime.strptime(valid_until, '%Y-%m-%d').date() + valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format for valid until'), 'error') - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients()) - + flash(_("Invalid date format for valid until"), "error") + return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients()) + # Update quote quote.title = title quote.description = description.strip() if description else None @@ -360,49 +374,51 @@ def edit_quote(quote_id): quote.terms = terms.strip() if terms else None quote.payment_terms = payment_terms.strip() if payment_terms else None quote.visible_to_client = visible_to_client - + # Update discount fields quote.discount_type = discount_type if discount_type else None quote.discount_amount = discount_amount_decimal if discount_amount_decimal else None quote.discount_reason = discount_reason.strip() if discount_reason else None quote.coupon_code = coupon_code.upper().strip() if coupon_code else None - + # Update line items - item_ids = request.form.getlist('item_id[]') - item_descriptions = request.form.getlist('item_description[]') - item_quantities = request.form.getlist('item_quantity[]') - item_prices = request.form.getlist('item_price[]') - item_units = request.form.getlist('item_unit[]') - + item_ids = request.form.getlist("item_id[]") + item_descriptions = request.form.getlist("item_description[]") + item_quantities = request.form.getlist("item_quantity[]") + item_prices = request.form.getlist("item_price[]") + item_units = request.form.getlist("item_unit[]") + # Delete items not in the form existing_item_ids = {int(id) for id in item_ids if id} for item in quote.items: if item.id not in existing_item_ids: db.session.delete(item) - + # Update or create items - item_stock_ids = request.form.getlist('item_stock_item_id[]') - item_warehouse_ids = request.form.getlist('item_warehouse_id[]') - + item_stock_ids = request.form.getlist("item_stock_item_id[]") + item_warehouse_ids = request.form.getlist("item_warehouse_id[]") + # Pad lists to match length while len(item_stock_ids) < len(item_ids): - item_stock_ids.append('') + item_stock_ids.append("") while len(item_warehouse_ids) < len(item_ids): - item_warehouse_ids.append('') - - for item_id, desc, qty, price, unit, stock_id, wh_id in zip(item_ids, item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids): + item_warehouse_ids.append("") + + for item_id, desc, qty, price, unit, stock_id, wh_id in zip( + item_ids, item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids + ): if desc.strip(): try: stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None - + if item_id: # Update existing item item = QuoteItem.query.get(item_id) if item and item.quote_id == quote.id: item.description = desc.strip() - item.quantity = Decimal(qty) if qty else Decimal('1') - item.unit_price = Decimal(price) if price else Decimal('0') + item.quantity = Decimal(qty) if qty else Decimal("1") + item.unit_price = Decimal(price) if price else Decimal("0") item.total_amount = item.quantity * item.unit_price item.unit = unit.strip() if unit else None item.stock_item_id = stock_item_id @@ -413,141 +429,180 @@ def edit_quote(quote_id): item = QuoteItem( quote_id=quote.id, description=desc.strip(), - quantity=Decimal(qty) if qty else Decimal('1'), - unit_price=Decimal(price) if price else Decimal('0'), + quantity=Decimal(qty) if qty else Decimal("1"), + unit_price=Decimal(price) if price else Decimal("0"), unit=unit.strip() if unit else None, stock_item_id=stock_item_id, - warehouse_id=warehouse_id + warehouse_id=warehouse_id, ) db.session.add(item) except (ValueError, InvalidOperation): pass # Skip invalid items - + quote.calculate_totals() - - if not safe_commit('edit_quote', {'quote_id': quote_id}): - flash(_('Could not update quote due to a database error. Please check server logs.'), 'error') + + if not safe_commit("edit_quote", {"quote_id": quote_id}): + flash(_("Could not update quote due to a database error. Please check server logs."), "error") from app.models import StockItem, Warehouse import json + stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - stock_items_json = json.dumps([{'id': item.id, 'sku': item.sku, 'name': item.name, 'default_price': float(item.default_price) if item.default_price else None, 'unit': item.unit or 'pcs', 'description': item.name} for item in stock_items]) - warehouses_json = json.dumps([{'id': wh.id, 'code': wh.code, 'name': wh.name} for wh in warehouses]) - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients(), stock_items=stock_items, warehouses=warehouses, stock_items_json=stock_items_json, warehouses_json=warehouses_json) - - log_event("quote.updated", - user_id=current_user.id, - quote_id=quote.id, - quote_title=title) - track_event(current_user.id, "quote.updated", { - "quote_id": quote.id, - "quote_title": title - }) - - flash(_('Quote updated successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + stock_items_json = json.dumps( + [ + { + "id": item.id, + "sku": item.sku, + "name": item.name, + "default_price": float(item.default_price) if item.default_price else None, + "unit": item.unit or "pcs", + "description": item.name, + } + for item in stock_items + ] + ) + warehouses_json = json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses]) + return render_template( + "quotes/edit.html", + quote=quote, + clients=Client.get_active_clients(), + stock_items=stock_items, + warehouses=warehouses, + stock_items_json=stock_items_json, + warehouses_json=warehouses_json, + ) + + log_event("quote.updated", user_id=current_user.id, quote_id=quote.id, quote_title=title) + track_event(current_user.id, "quote.updated", {"quote_id": quote.id, "quote_title": title}) + + flash(_("Quote updated successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + from app.models import StockItem, Warehouse import json + stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all() warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all() - stock_items_json = json.dumps([{'id': item.id, 'sku': item.sku, 'name': item.name, 'default_price': float(item.default_price) if item.default_price else None, 'unit': item.unit or 'pcs', 'description': item.name} for item in stock_items]) - warehouses_json = json.dumps([{'id': wh.id, 'code': wh.code, 'name': wh.name} for wh in warehouses]) - return render_template('quotes/edit.html', quote=quote, clients=Client.get_active_clients(), stock_items=stock_items, warehouses=warehouses, stock_items_json=stock_items_json, warehouses_json=warehouses_json) + stock_items_json = json.dumps( + [ + { + "id": item.id, + "sku": item.sku, + "name": item.name, + "default_price": float(item.default_price) if item.default_price else None, + "unit": item.unit or "pcs", + "description": item.name, + } + for item in stock_items + ] + ) + warehouses_json = json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses]) + return render_template( + "quotes/edit.html", + quote=quote, + clients=Client.get_active_clients(), + stock_items=stock_items, + warehouses=warehouses, + stock_items_json=stock_items_json, + warehouses_json=warehouses_json, + ) + -@quotes_bp.route('/quotes//send', methods=['POST']) +@quotes_bp.route("/quotes//send", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def send_quote(quote_id): """Send an quote to the client""" quote = Quote.query.get_or_404(quote_id) - + if not quote.can_be_sent: - if quote.requires_approval and quote.approval_status != 'approved': - flash(_('Quote must be approved before it can be sent'), 'error') + if quote.requires_approval and quote.approval_status != "approved": + flash(_("Quote must be approved before it can be sent"), "error") else: - flash(_('Only draft quotes can be sent'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("Only draft quotes can be sent"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + try: quote.send() except ValueError as e: - flash(_('Cannot send quote: %(error)s', error=str(e)), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("Cannot send quote: %(error)s", error=str(e)), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Reserve stock for quote items if enabled from app.models import StockReservation import os - - auto_reserve_on_send = os.getenv('INVENTORY_AUTO_RESERVE_ON_QUOTE_SENT', 'false').lower() == 'true' + + auto_reserve_on_send = os.getenv("INVENTORY_AUTO_RESERVE_ON_QUOTE_SENT", "false").lower() == "true" if auto_reserve_on_send: for item in quote.items: if item.is_stock_item and item.stock_item_id and item.warehouse_id: try: - expires_in_days = get_setting('INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS', 30) + expires_in_days = get_setting("INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS", 30) StockReservation.create_reservation( stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, quantity=item.quantity, - reservation_type='quote', + reservation_type="quote", reservation_id=quote.id, reserved_by=current_user.id, - expires_in_days=expires_in_days + expires_in_days=expires_in_days, ) except ValueError as e: - flash(_('Warning: Could not reserve stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning') - - if not safe_commit('send_quote', {'quote_id': quote_id}): - flash(_('Could not send quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash( + _( + "Warning: Could not reserve stock for item %(item)s: %(error)s", + item=item.description, + error=str(e), + ), + "warning", + ) + + if not safe_commit("send_quote", {"quote_id": quote_id}): + flash(_("Could not send quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Send notifications from app.utils.email import send_quote_sent_notification from app.models import User - + # Notify quote creator if quote.creator and quote.creator.email: send_quote_sent_notification(quote, quote.creator) - + # Notify admins - admins = User.query.filter_by(role='admin', is_active=True).all() + admins = User.query.filter_by(role="admin", is_active=True).all() for admin in admins: if admin.id != quote.creator_id and admin.email: send_quote_sent_notification(quote, admin) - - log_event("quote.sent", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title) - track_event(current_user.id, "quote.sent", { - "quote_id": quote.id, - "quote_title": quote.title - }) - - flash(_('Quote sent successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - -@quotes_bp.route('/quotes//accept', methods=['GET', 'POST']) + + log_event("quote.sent", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title) + track_event(current_user.id, "quote.sent", {"quote_id": quote.id, "quote_title": quote.title}) + + flash(_("Quote sent successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes//accept", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('accept_quotes') +@admin_or_permission_required("accept_quotes") def accept_quote(quote_id): """Accept an quote and create a project""" quote = Quote.query.get_or_404(quote_id) - + if not quote.can_be_accepted: - flash(_('This quote cannot be accepted'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if request.method == 'POST': + flash(_("This quote cannot be accepted"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if request.method == "POST": # Create project from quote - project_name = request.form.get('project_name', quote.title).strip() + project_name = request.form.get("project_name", quote.title).strip() if not project_name: project_name = quote.title - + # Calculate totals to get budget amount quote.calculate_totals() budget_amount = quote.total_amount - + # Create project project = Project( name=project_name, @@ -556,213 +611,208 @@ def accept_quote(quote_id): billable=True, budget_amount=budget_amount, quote_id=quote.id, - status='active' + status="active", ) - + db.session.add(project) - + # Accept the quote try: db.session.flush() # Get project ID quote.accept(current_user.id, project.id) except ValueError as e: - flash(_('Could not accept quote: %(error)s', error=str(e)), 'error') + flash(_("Could not accept quote: %(error)s", error=str(e)), "error") db.session.rollback() - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Reserve stock for quote items when accepted (if not already reserved) from app.models import StockReservation import os - + for item in quote.items: if item.is_stock_item and item.stock_item_id and item.warehouse_id: # Check if reservation already exists existing = StockReservation.query.filter_by( stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, - reservation_type='quote', + reservation_type="quote", reservation_id=quote.id, - status='reserved' + status="reserved", ).first() - + if not existing: try: - expires_in_days = int(os.getenv('INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS', '30')) + expires_in_days = int(os.getenv("INVENTORY_QUOTE_RESERVATION_EXPIRY_DAYS", "30")) StockReservation.create_reservation( stock_item_id=item.stock_item_id, warehouse_id=item.warehouse_id, quantity=item.quantity, - reservation_type='quote', + reservation_type="quote", reservation_id=quote.id, reserved_by=current_user.id, - expires_in_days=expires_in_days + expires_in_days=expires_in_days, ) except ValueError as e: - flash(_('Warning: Could not reserve stock for item %(item)s: %(error)s', item=item.description, error=str(e)), 'warning') - - if not safe_commit('accept_quote', {'quote_id': quote_id, 'project_id': project.id}): - flash(_('Could not accept quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash( + _( + "Warning: Could not reserve stock for item %(item)s: %(error)s", + item=item.description, + error=str(e), + ), + "warning", + ) + + if not safe_commit("accept_quote", {"quote_id": quote_id, "project_id": project.id}): + flash(_("Could not accept quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Send notifications from app.utils.email import send_quote_accepted_notification from app.models import User - + # Notify quote creator if quote.creator and quote.creator.email: send_quote_accepted_notification(quote, quote.creator) - + # Notify admins - admins = User.query.filter_by(role='admin', is_active=True).all() + admins = User.query.filter_by(role="admin", is_active=True).all() for admin in admins: if admin.id != quote.creator_id and admin.email: send_quote_accepted_notification(quote, admin) - - log_event("quote.accepted", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title, - project_id=project.id) - track_event(current_user.id, "quote.accepted", { - "quote_id": quote.id, - "quote_title": quote.title, - "project_id": project.id - }) - - flash(_('Quote accepted and project created successfully'), 'success') - return redirect(url_for('projects.view_project', project_id=project.id)) - - return render_template('quotes/accept.html', quote=quote) - -@quotes_bp.route('/quotes//reject', methods=['POST']) + + log_event( + "quote.accepted", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title, project_id=project.id + ) + track_event( + current_user.id, + "quote.accepted", + {"quote_id": quote.id, "quote_title": quote.title, "project_id": project.id}, + ) + + flash(_("Quote accepted and project created successfully"), "success") + return redirect(url_for("projects.view_project", project_id=project.id)) + + return render_template("quotes/accept.html", quote=quote) + + +@quotes_bp.route("/quotes//reject", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def reject_quote(quote_id): """Reject an quote""" quote = Quote.query.get_or_404(quote_id) - - if quote.status not in ['sent', 'draft']: - flash(_('This quote cannot be rejected'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + + if quote.status not in ["sent", "draft"]: + flash(_("This quote cannot be rejected"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + try: quote.reject() except ValueError as e: - flash(_('Could not reject quote: %(error)s', error=str(e)), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if not safe_commit('reject_quote', {'quote_id': quote_id}): - flash(_('Could not reject quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.rejected", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title) - track_event(current_user.id, "quote.rejected", { - "quote_id": quote.id, - "quote_title": quote.title - }) - - flash(_('Quote rejected'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - -@quotes_bp.route('/quotes//delete', methods=['POST']) + flash(_("Could not reject quote: %(error)s", error=str(e)), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if not safe_commit("reject_quote", {"quote_id": quote_id}): + flash(_("Could not reject quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event("quote.rejected", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title) + track_event(current_user.id, "quote.rejected", {"quote_id": quote.id, "quote_title": quote.title}) + + flash(_("Quote rejected"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes//delete", methods=["POST"]) @login_required -@admin_or_permission_required('delete_quotes') +@admin_or_permission_required("delete_quotes") def delete_quote(quote_id): """Delete an quote""" quote = Quote.query.get_or_404(quote_id) - + # Only allow deleting draft or rejected quotes - if quote.status not in ['draft', 'rejected']: - flash(_('Only draft or rejected quotes can be deleted'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + if quote.status not in ["draft", "rejected"]: + flash(_("Only draft or rejected quotes can be deleted"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + quote_title = quote.title db.session.delete(quote) - - if not safe_commit('delete_quote', {'quote_id': quote_id}): - flash(_('Could not delete quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.deleted", - user_id=current_user.id, - quote_id=quote_id, - quote_title=quote_title) - track_event(current_user.id, "quote.deleted", { - "quote_id": quote_id, - "quote_title": quote_title - }) - - flash(_('Quote deleted successfully'), 'success') - return redirect(url_for('quotes.list_quotes')) - - -@quotes_bp.route('/quotes//attachments/upload', methods=['POST']) + + if not safe_commit("delete_quote", {"quote_id": quote_id}): + flash(_("Could not delete quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event("quote.deleted", user_id=current_user.id, quote_id=quote_id, quote_title=quote_title) + track_event(current_user.id, "quote.deleted", {"quote_id": quote_id, "quote_title": quote_title}) + + flash(_("Quote deleted successfully"), "success") + return redirect(url_for("quotes.list_quotes")) + + +@quotes_bp.route("/quotes//attachments/upload", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def upload_attachment(quote_id): """Upload an attachment to a quote""" from werkzeug.utils import secure_filename from flask import current_app import os from datetime import datetime - + quote = Quote.query.get_or_404(quote_id) - + # Check permissions if not current_user.is_admin and quote.created_by != current_user.id: - flash(_('You do not have permission to upload attachments to this quote'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("You do not have permission to upload attachments to this quote"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # File upload configuration - ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'doc', 'docx', 'txt', 'xls', 'xlsx', 'zip', 'rar'} - UPLOAD_FOLDER = 'uploads/quote_attachments' + ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "zip", "rar"} + UPLOAD_FOLDER = "uploads/quote_attachments" MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB - + def allowed_file(filename): - return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - - if 'file' not in request.files: - flash(_('No file provided'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - file = request.files['file'] - if file.filename == '': - flash(_('No file selected'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS + + if "file" not in request.files: + flash(_("No file provided"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + file = request.files["file"] + if file.filename == "": + flash(_("No file selected"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + if not allowed_file(file.filename): - flash(_('File type not allowed'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("File type not allowed"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Check file size file.seek(0, os.SEEK_END) file_size = file.tell() file.seek(0) - + if file_size > MAX_FILE_SIZE: - flash(_('File size exceeds maximum allowed size (10 MB)'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("File size exceeds maximum allowed size (10 MB)"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Save file original_filename = secure_filename(file.filename) - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = f"{quote_id}_{timestamp}_{original_filename}" - + # Ensure upload directory exists - upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER) + upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER) os.makedirs(upload_dir, exist_ok=True) - + file_path = os.path.join(upload_dir, filename) file.save(file_path) - + # Get file info - mime_type = file.content_type or 'application/octet-stream' - description = request.form.get('description', '').strip() or None - is_visible_to_client = request.form.get('is_visible_to_client', 'false').lower() == 'true' - + mime_type = file.content_type or "application/octet-stream" + description = request.form.get("description", "").strip() or None + is_visible_to_client = request.form.get("is_visible_to_client", "false").lower() == "true" + # Create attachment record attachment = QuoteAttachment( quote_id=quote_id, @@ -773,305 +823,288 @@ def allowed_file(filename): uploaded_by=current_user.id, mime_type=mime_type, description=description, - is_visible_to_client=is_visible_to_client + is_visible_to_client=is_visible_to_client, ) - + db.session.add(attachment) - - if not safe_commit('upload_quote_attachment', {'quote_id': quote_id, 'attachment_id': attachment.id}): - flash(_('Could not upload attachment due to a database error. Please check server logs.'), 'error') + + if not safe_commit("upload_quote_attachment", {"quote_id": quote_id, "attachment_id": attachment.id}): + flash(_("Could not upload attachment due to a database error. Please check server logs."), "error") # Clean up uploaded file try: os.remove(file_path) except: pass - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.attachment.uploaded", - user_id=current_user.id, - quote_id=quote_id, - attachment_id=attachment.id, - filename=original_filename) - track_event(current_user.id, "quote.attachment.uploaded", { - "quote_id": quote_id, - "attachment_id": attachment.id, - "filename": original_filename - }) - - flash(_('Attachment uploaded successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - -@quotes_bp.route('/quotes/attachments//download') + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event( + "quote.attachment.uploaded", + user_id=current_user.id, + quote_id=quote_id, + attachment_id=attachment.id, + filename=original_filename, + ) + track_event( + current_user.id, + "quote.attachment.uploaded", + {"quote_id": quote_id, "attachment_id": attachment.id, "filename": original_filename}, + ) + + flash(_("Attachment uploaded successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes/attachments//download") @login_required def download_attachment(attachment_id): """Download a quote attachment""" from flask import send_file, current_app import os - + attachment = QuoteAttachment.query.get_or_404(attachment_id) quote = attachment.quote - + # Check permissions if not current_user.is_admin and quote.created_by != current_user.id: - flash(_('You do not have permission to download this attachment'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote.id)) - + flash(_("You do not have permission to download this attachment"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote.id)) + # Build file path - file_path = os.path.join(current_app.root_path, '..', attachment.file_path) - + file_path = os.path.join(current_app.root_path, "..", attachment.file_path) + if not os.path.exists(file_path): - flash(_('File not found'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote.id)) - + flash(_("File not found"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote.id)) + return send_file( - file_path, - as_attachment=True, - download_name=attachment.original_filename, - mimetype=attachment.mime_type + file_path, as_attachment=True, download_name=attachment.original_filename, mimetype=attachment.mime_type ) -@quotes_bp.route('/quotes/attachments//delete', methods=['POST']) +@quotes_bp.route("/quotes/attachments//delete", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def delete_attachment(attachment_id): """Delete a quote attachment""" from flask import current_app import os - + attachment = QuoteAttachment.query.get_or_404(attachment_id) quote = attachment.quote - + # Check permissions if not current_user.is_admin and quote.created_by != current_user.id: - flash(_('You do not have permission to delete this attachment'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote.id)) - + flash(_("You do not have permission to delete this attachment"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote.id)) + # Delete file - file_path = os.path.join(current_app.root_path, '..', attachment.file_path) + file_path = os.path.join(current_app.root_path, "..", attachment.file_path) if os.path.exists(file_path): try: os.remove(file_path) except Exception as e: current_app.logger.error(f"Failed to delete attachment file: {e}") - + # Delete database record attachment_id_for_log = attachment.id quote_id = quote.id db.session.delete(attachment) - - if not safe_commit('delete_quote_attachment', {'attachment_id': attachment_id_for_log}): - flash(_('Could not delete attachment due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.attachment.deleted", - user_id=current_user.id, - quote_id=quote_id, - attachment_id=attachment_id_for_log) - track_event(current_user.id, "quote.attachment.deleted", { - "quote_id": quote_id, - "attachment_id": attachment_id_for_log - }) - - flash(_('Attachment deleted successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - -@quotes_bp.route('/quotes//request-approval', methods=['POST']) + + if not safe_commit("delete_quote_attachment", {"attachment_id": attachment_id_for_log}): + flash(_("Could not delete attachment due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event( + "quote.attachment.deleted", user_id=current_user.id, quote_id=quote_id, attachment_id=attachment_id_for_log + ) + track_event( + current_user.id, "quote.attachment.deleted", {"quote_id": quote_id, "attachment_id": attachment_id_for_log} + ) + + flash(_("Attachment deleted successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes//request-approval", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def request_approval(quote_id): """Request approval for a quote""" quote = Quote.query.get_or_404(quote_id) - + # Check permissions if not current_user.is_admin and quote.created_by != current_user.id: - flash(_('You do not have permission to request approval for this quote'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("You do not have permission to request approval for this quote"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + if not quote.requires_approval: - flash(_('This quote does not require approval'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("This quote does not require approval"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + try: quote.request_approval() except ValueError as e: - flash(_('Cannot request approval: %(error)s', error=str(e)), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if not safe_commit('request_quote_approval', {'quote_id': quote_id}): - flash(_('Could not request approval due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("Cannot request approval: %(error)s", error=str(e)), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if not safe_commit("request_quote_approval", {"quote_id": quote_id}): + flash(_("Could not request approval due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Send notification to approvers from app.utils.email import send_quote_approval_request_notification from app.models import User - + # Notify admins (default approvers) - admins = User.query.filter_by(role='admin', is_active=True).all() + admins = User.query.filter_by(role="admin", is_active=True).all() for admin in admins: if admin.email: send_quote_approval_request_notification(quote, admin) - - log_event("quote.approval.requested", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title) - track_event(current_user.id, "quote.approval.requested", { - "quote_id": quote.id, - "quote_title": quote.title - }) - - flash(_('Approval requested successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - -@quotes_bp.route('/quotes//approve', methods=['POST']) + + log_event("quote.approval.requested", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title) + track_event(current_user.id, "quote.approval.requested", {"quote_id": quote.id, "quote_title": quote.title}) + + flash(_("Approval requested successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes//approve", methods=["POST"]) @login_required -@admin_or_permission_required('approve_quotes') +@admin_or_permission_required("approve_quotes") def approve_quote(quote_id): """Approve a quote""" quote = Quote.query.get_or_404(quote_id) - + if not quote.requires_approval: - flash(_('This quote does not require approval'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if quote.approval_status != 'pending': - flash(_('This quote is not pending approval'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - notes = request.form.get('notes', '').strip() or None - + flash(_("This quote does not require approval"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if quote.approval_status != "pending": + flash(_("This quote is not pending approval"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + notes = request.form.get("notes", "").strip() or None + try: quote.approve(current_user.id, notes) except ValueError as e: - flash(_('Cannot approve quote: %(error)s', error=str(e)), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if not safe_commit('approve_quote', {'quote_id': quote_id}): - flash(_('Could not approve quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("Cannot approve quote: %(error)s", error=str(e)), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if not safe_commit("approve_quote", {"quote_id": quote_id}): + flash(_("Could not approve quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Send notification to quote creator from app.utils.email import send_quote_approved_notification + if quote.creator and quote.creator.email: send_quote_approved_notification(quote, quote.creator) - - log_event("quote.approved", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title) - track_event(current_user.id, "quote.approved", { - "quote_id": quote.id, - "quote_title": quote.title - }) - - flash(_('Quote approved successfully'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - -@quotes_bp.route('/quotes//reject-approval', methods=['POST']) + + log_event("quote.approved", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title) + track_event(current_user.id, "quote.approved", {"quote_id": quote.id, "quote_title": quote.title}) + + flash(_("Quote approved successfully"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes//reject-approval", methods=["POST"]) @login_required -@admin_or_permission_required('approve_quotes') +@admin_or_permission_required("approve_quotes") def reject_approval(quote_id): """Reject a quote in approval workflow""" quote = Quote.query.get_or_404(quote_id) - + if not quote.requires_approval: - flash(_('This quote does not require approval'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if quote.approval_status != 'pending': - flash(_('This quote is not pending approval'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - reason = request.form.get('reason', '').strip() + flash(_("This quote does not require approval"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if quote.approval_status != "pending": + flash(_("This quote is not pending approval"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + reason = request.form.get("reason", "").strip() if not reason: - flash(_('Rejection reason is required'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("Rejection reason is required"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + try: quote.reject_approval(current_user.id, reason) except ValueError as e: - flash(_('Cannot reject quote: %(error)s', error=str(e)), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - if not safe_commit('reject_quote_approval', {'quote_id': quote_id}): - flash(_('Could not reject quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - + flash(_("Cannot reject quote: %(error)s", error=str(e)), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + if not safe_commit("reject_quote_approval", {"quote_id": quote_id}): + flash(_("Could not reject quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + # Send notification to quote creator from app.utils.email import send_quote_approval_rejected_notification + if quote.creator and quote.creator.email: send_quote_approval_rejected_notification(quote, quote.creator) - - log_event("quote.approval.rejected", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title) - track_event(current_user.id, "quote.approval.rejected", { - "quote_id": quote.id, - "quote_title": quote.title - }) - - flash(_('Quote approval rejected'), 'success') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - -@quotes_bp.route('/quotes/templates') + + log_event("quote.approval.rejected", user_id=current_user.id, quote_id=quote.id, quote_title=quote.title) + track_event(current_user.id, "quote.approval.rejected", {"quote_id": quote.id, "quote_title": quote.title}) + + flash(_("Quote approval rejected"), "success") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + +@quotes_bp.route("/quotes/templates") @login_required def list_templates(): """List all quote templates""" templates = QuoteTemplate.get_user_templates(current_user.id, include_public=True) - return render_template('quotes/templates.html', templates=templates) + return render_template("quotes/templates.html", templates=templates) -@quotes_bp.route('/quotes/templates/create', methods=['GET', 'POST']) +@quotes_bp.route("/quotes/templates/create", methods=["GET", "POST"]) @login_required -@admin_or_permission_required('create_quotes') +@admin_or_permission_required("create_quotes") def create_template(): """Create a new quote template""" - if request.method == 'POST': - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() or None - + if request.method == "POST": + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() or None + if not name: - flash(_('Template name is required'), 'error') - return render_template('quotes/template_form.html') - + flash(_("Template name is required"), "error") + return render_template("quotes/template_form.html") + # Get template settings - default_tax_rate = request.form.get('default_tax_rate', '0').strip() - default_currency_code = request.form.get('default_currency_code', 'EUR').strip() - default_payment_terms = request.form.get('default_payment_terms', '').strip() or None - default_terms = request.form.get('default_terms', '').strip() or None - default_valid_until_days = request.form.get('default_valid_until_days', type=int) or 30 - default_requires_approval = request.form.get('default_requires_approval', 'false').lower() == 'true' - default_approval_level = request.form.get('default_approval_level', type=int) or 1 - is_public = request.form.get('is_public', 'false').lower() == 'true' - + default_tax_rate = request.form.get("default_tax_rate", "0").strip() + default_currency_code = request.form.get("default_currency_code", "EUR").strip() + default_payment_terms = request.form.get("default_payment_terms", "").strip() or None + default_terms = request.form.get("default_terms", "").strip() or None + default_valid_until_days = request.form.get("default_valid_until_days", type=int) or 30 + default_requires_approval = request.form.get("default_requires_approval", "false").lower() == "true" + default_approval_level = request.form.get("default_approval_level", type=int) or 1 + is_public = request.form.get("is_public", "false").lower() == "true" + try: - default_tax_rate = Decimal(default_tax_rate) if default_tax_rate else Decimal('0') + default_tax_rate = Decimal(default_tax_rate) if default_tax_rate else Decimal("0") except (ValueError, InvalidOperation): - default_tax_rate = Decimal('0') - + default_tax_rate = Decimal("0") + # Get default items - item_descriptions = request.form.getlist('item_description[]') - item_quantities = request.form.getlist('item_quantity[]') - item_prices = request.form.getlist('item_price[]') - item_units = request.form.getlist('item_unit[]') - + item_descriptions = request.form.getlist("item_description[]") + item_quantities = request.form.getlist("item_quantity[]") + item_prices = request.form.getlist("item_price[]") + item_units = request.form.getlist("item_unit[]") + default_items = [] for desc, qty, price, unit in zip(item_descriptions, item_quantities, item_prices, item_units): if desc.strip(): - default_items.append({ - 'description': desc.strip(), - 'quantity': float(qty) if qty else 1, - 'unit_price': float(price) if price else 0, - 'unit': unit.strip() if unit else None - }) - + default_items.append( + { + "description": desc.strip(), + "quantity": float(qty) if qty else 1, + "unit_price": float(price) if price else 0, + "unit": unit.strip() if unit else None, + } + ) + # Create template template = QuoteTemplate( name=name, @@ -1084,61 +1117,57 @@ def create_template(): default_valid_until_days=default_valid_until_days, default_requires_approval=default_requires_approval, default_approval_level=default_approval_level, - is_public=is_public + is_public=is_public, ) template.items_list = default_items if default_items else None - + db.session.add(template) - - if not safe_commit('create_quote_template', {'template_id': template.id}): - flash(_('Could not create template due to a database error. Please check server logs.'), 'error') - return render_template('quotes/template_form.html') - - log_event("quote.template.created", - user_id=current_user.id, - template_id=template.id, - template_name=name) - track_event(current_user.id, "quote.template.created", { - "template_id": template.id, - "template_name": name - }) - - flash(_('Template created successfully'), 'success') - return redirect(url_for('quotes.list_templates')) - - return render_template('quotes/template_form.html') - - -@quotes_bp.route('/quotes/templates//save-from-quote', methods=['POST']) + + if not safe_commit("create_quote_template", {"template_id": template.id}): + flash(_("Could not create template due to a database error. Please check server logs."), "error") + return render_template("quotes/template_form.html") + + log_event("quote.template.created", user_id=current_user.id, template_id=template.id, template_name=name) + track_event(current_user.id, "quote.template.created", {"template_id": template.id, "template_name": name}) + + flash(_("Template created successfully"), "success") + return redirect(url_for("quotes.list_templates")) + + return render_template("quotes/template_form.html") + + +@quotes_bp.route("/quotes/templates//save-from-quote", methods=["POST"]) @login_required -@admin_or_permission_required('create_quotes') +@admin_or_permission_required("create_quotes") def save_template_from_quote(template_id): """Save current quote as a template""" quote = Quote.query.get_or_404(quote_id) - + # Check permissions if not current_user.is_admin and quote.created_by != current_user.id: - flash(_('You do not have permission to create a template from this quote'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - name = request.form.get('name', '').strip() + flash(_("You do not have permission to create a template from this quote"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + name = request.form.get("name", "").strip() if not name: - flash(_('Template name is required'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - description = request.form.get('description', '').strip() or None - is_public = request.form.get('is_public', 'false').lower() == 'true' - + flash(_("Template name is required"), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + description = request.form.get("description", "").strip() or None + is_public = request.form.get("is_public", "false").lower() == "true" + # Extract items default_items = [] for item in quote.items: - default_items.append({ - 'description': item.description, - 'quantity': float(item.quantity), - 'unit_price': float(item.unit_price), - 'unit': item.unit - }) - + default_items.append( + { + "description": item.description, + "quantity": float(item.quantity), + "unit_price": float(item.unit_price), + "unit": item.unit, + } + ) + # Create template template = QuoteTemplate( name=name, @@ -1151,172 +1180,168 @@ def save_template_from_quote(template_id): default_valid_until_days=30, # Default default_requires_approval=quote.requires_approval, default_approval_level=quote.approval_level or 1, - is_public=is_public + is_public=is_public, ) template.items_list = default_items if default_items else None - + db.session.add(template) - - if not safe_commit('save_quote_template', {'template_id': template.id, 'quote_id': quote_id}): - flash(_('Could not save template due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) - - log_event("quote.template.saved_from_quote", - user_id=current_user.id, - template_id=template.id, - quote_id=quote_id) - track_event(current_user.id, "quote.template.saved_from_quote", { - "template_id": template.id, - "quote_id": quote_id - }) - - flash(_('Template saved successfully'), 'success') - return redirect(url_for('quotes.list_templates')) - -@quotes_bp.route('/quotes//export-pdf', methods=['GET']) + + if not safe_commit("save_quote_template", {"template_id": template.id, "quote_id": quote_id}): + flash(_("Could not save template due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + + log_event("quote.template.saved_from_quote", user_id=current_user.id, template_id=template.id, quote_id=quote_id) + track_event(current_user.id, "quote.template.saved_from_quote", {"template_id": template.id, "quote_id": quote_id}) + + flash(_("Template saved successfully"), "success") + return redirect(url_for("quotes.list_templates")) + + +@quotes_bp.route("/quotes//export-pdf", methods=["GET"]) @login_required def export_quote_pdf(quote_id): """Export quote as PDF""" quote = Quote.query.get_or_404(quote_id) - + if not current_user.is_admin and quote.created_by != current_user.id: - flash(_('You do not have permission to export this quote'), 'error') - return redirect(request.referrer or url_for('quotes.list_quotes')) - + flash(_("You do not have permission to export this quote"), "error") + return redirect(request.referrer or url_for("quotes.list_quotes")) + # Get page size from query parameter, default to A4 - page_size = request.args.get('size', 'A4') - + page_size = request.args.get("size", "A4") + # Validate page size - valid_sizes = ['A4', 'Letter', 'Legal', 'A3', 'A5', 'Tabloid'] + valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: - page_size = 'A4' - + page_size = "A4" + try: from app.utils.pdf_generator import QuotePDFGenerator from app.models import Settings import io from flask import send_file - + settings = Settings.get_settings() pdf_generator = QuotePDFGenerator(quote, settings=settings, page_size=page_size) pdf_bytes = pdf_generator.generate_pdf() - filename = f'quote_{quote.quote_number}_{page_size}.pdf' - return send_file( - io.BytesIO(pdf_bytes), - mimetype='application/pdf', - as_attachment=True, - download_name=filename - ) + filename = f"quote_{quote.quote_number}_{page_size}.pdf" + return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename) except ImportError: # Fallback if QuotePDFGenerator doesn't exist yet from app.utils.pdf_generator_fallback import QuotePDFGeneratorFallback from app.models import Settings import io from flask import send_file - + settings = Settings.get_settings() pdf_generator = QuotePDFGeneratorFallback(quote, settings=settings) pdf_bytes = pdf_generator.generate_pdf() - filename = f'quote_{quote.quote_number}_{page_size}.pdf' - return send_file( - io.BytesIO(pdf_bytes), - mimetype='application/pdf', - as_attachment=True, - download_name=filename - ) + filename = f"quote_{quote.quote_number}_{page_size}.pdf" + return send_file(io.BytesIO(pdf_bytes), mimetype="application/pdf", as_attachment=True, download_name=filename) except Exception as e: current_app.logger.error(f"Error generating quote PDF: {e}", exc_info=True) - flash(_('Error generating PDF: %(error)s', error=str(e)), 'error') - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) + flash(_("Error generating PDF: %(error)s", error=str(e)), "error") + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) -@quotes_bp.route('/quotes//send-email', methods=['POST']) + +@quotes_bp.route("/quotes//send-email", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def send_quote_email(quote_id): """Send quote via email""" quote = Quote.query.get_or_404(quote_id) - + # Get recipient email from request - recipient_email = request.form.get('recipient_email', '').strip() or request.json.get('recipient_email', '').strip() if request.is_json else '' - + recipient_email = ( + request.form.get("recipient_email", "").strip() or request.json.get("recipient_email", "").strip() + if request.is_json + else "" + ) + if not recipient_email: # Try to use quote client email if quote.client and quote.client.email: recipient_email = quote.client.email - + if not recipient_email: - return jsonify({'error': _('Recipient email address is required')}), 400 - + return jsonify({"error": _("Recipient email address is required")}), 400 + # Get custom message if provided - custom_message = request.form.get('custom_message', '').strip() or (request.json.get('custom_message', '').strip() if request.is_json else '') - + custom_message = request.form.get("custom_message", "").strip() or ( + request.json.get("custom_message", "").strip() if request.is_json else "" + ) + try: from app.utils.email import send_quote_email + success, result, message = send_quote_email( quote=quote, recipient_email=recipient_email, sender_user=current_user, - custom_message=custom_message if custom_message else None + custom_message=custom_message if custom_message else None, ) - + if success: - flash(_('Quote sent successfully to %(email)s', email=recipient_email), 'success') - log_event("quote.emailed", - user_id=current_user.id, - quote_id=quote.id, - quote_title=quote.title, - recipient_email=recipient_email) - track_event(current_user.id, "quote.emailed", { - "quote_id": quote.id, - "quote_title": quote.title, - "recipient_email": recipient_email - }) + flash(_("Quote sent successfully to %(email)s", email=recipient_email), "success") + log_event( + "quote.emailed", + user_id=current_user.id, + quote_id=quote.id, + quote_title=quote.title, + recipient_email=recipient_email, + ) + track_event( + current_user.id, + "quote.emailed", + {"quote_id": quote.id, "quote_title": quote.title, "recipient_email": recipient_email}, + ) if request.is_json: - return jsonify({'success': True, 'message': message}) - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) + return jsonify({"success": True, "message": message}) + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) else: - flash(_('Failed to send quote: %(error)s', error=message), 'error') + flash(_("Failed to send quote: %(error)s", error=message), "error") if request.is_json: - return jsonify({'error': message}), 400 - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) + return jsonify({"error": message}), 400 + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) except Exception as e: current_app.logger.error(f"Error sending quote email: {e}", exc_info=True) - flash(_('Error sending email: %(error)s', error=str(e)), 'error') + flash(_("Error sending email: %(error)s", error=str(e)), "error") if request.is_json: - return jsonify({'error': str(e)}), 500 - return redirect(url_for('quotes.view_quote', quote_id=quote_id)) + return jsonify({"error": str(e)}), 500 + return redirect(url_for("quotes.view_quote", quote_id=quote_id)) + -@quotes_bp.route('/quotes//duplicate') +@quotes_bp.route("/quotes//duplicate") @login_required -@admin_or_permission_required('create_quotes') +@admin_or_permission_required("create_quotes") def duplicate_quote(quote_id): """Duplicate an existing quote""" from datetime import timedelta from app.utils.timezone import local_now - + original_quote = Quote.query.get_or_404(quote_id) - + # Check access permissions if not current_user.is_admin and original_quote.created_by != current_user.id: - flash(_('You do not have permission to duplicate this quote'), 'error') - return redirect(url_for('quotes.list_quotes')) - + flash(_("You do not have permission to duplicate this quote"), "error") + return redirect(url_for("quotes.list_quotes")) + # Generate new quote number new_quote_number = Quote.generate_quote_number() - + # Calculate new valid_until date (30 days from now, or extend original if it exists) if original_quote.valid_until: new_valid_until = local_now().date() + timedelta(days=30) else: new_valid_until = None - + # Create new quote new_quote = Quote( quote_number=new_quote_number, client_id=original_quote.client_id, title=original_quote.title, description=original_quote.description, - status='draft', # Always start as draft + status="draft", # Always start as draft valid_until=new_valid_until, notes=original_quote.notes, terms=original_quote.terms, @@ -1327,16 +1352,18 @@ def duplicate_quote(quote_id): currency_code=original_quote.currency_code, tax_rate=original_quote.tax_rate, discount_type=original_quote.discount_type, - discount_amount=original_quote.discount_amount, - discount_reason=original_quote.discount_reason, - coupon_code=original_quote.coupon_code - ) - + discount_amount=original_quote.discount_amount, + discount_reason=original_quote.discount_reason, + coupon_code=original_quote.coupon_code, + ) + db.session.add(new_quote) - if not safe_commit('duplicate_quote_create', {'source_quote_id': original_quote.id, 'new_quote_number': new_quote_number}): - flash(_('Could not duplicate quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.list_quotes')) - + if not safe_commit( + "duplicate_quote_create", {"source_quote_id": original_quote.id, "new_quote_number": new_quote_number} + ): + flash(_("Could not duplicate quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.list_quotes")) + # Duplicate quote items for original_item in original_quote.items: new_item = QuoteItem( @@ -1344,74 +1371,77 @@ def duplicate_quote(quote_id): description=original_item.description, quantity=original_item.quantity, unit_price=original_item.unit_price, - unit=original_item.unit + unit=original_item.unit, ) db.session.add(new_item) - + # Calculate totals new_quote.calculate_totals() - if not safe_commit('duplicate_quote_finalize', {'quote_id': new_quote.id}): - flash(_('Could not finalize duplicated quote due to a database error. Please check server logs.'), 'error') - return redirect(url_for('quotes.list_quotes')) - - flash(_('Quote %(quote_number)s created as duplicate', quote_number=new_quote_number), 'success') - log_event("quote.duplicated", - user_id=current_user.id, - quote_id=new_quote.id, - original_quote_id=original_quote.id, - quote_title=new_quote.title) - track_event(current_user.id, "quote.duplicated", { - "quote_id": new_quote.id, - "original_quote_id": original_quote.id, - "quote_title": new_quote.title - }) - return redirect(url_for('quotes.edit_quote', quote_id=new_quote.id)) - -@quotes_bp.route('/quotes/bulk_action', methods=['POST']) + if not safe_commit("duplicate_quote_finalize", {"quote_id": new_quote.id}): + flash(_("Could not finalize duplicated quote due to a database error. Please check server logs."), "error") + return redirect(url_for("quotes.list_quotes")) + + flash(_("Quote %(quote_number)s created as duplicate", quote_number=new_quote_number), "success") + log_event( + "quote.duplicated", + user_id=current_user.id, + quote_id=new_quote.id, + original_quote_id=original_quote.id, + quote_title=new_quote.title, + ) + track_event( + current_user.id, + "quote.duplicated", + {"quote_id": new_quote.id, "original_quote_id": original_quote.id, "quote_title": new_quote.title}, + ) + return redirect(url_for("quotes.edit_quote", quote_id=new_quote.id)) + + +@quotes_bp.route("/quotes/bulk_action", methods=["POST"]) @login_required -@admin_or_permission_required('edit_quotes') +@admin_or_permission_required("edit_quotes") def bulk_action(): """Perform bulk actions on selected quotes""" - action = request.form.get('action') - quote_ids = request.form.getlist('quote_ids[]') - + action = request.form.get("action") + quote_ids = request.form.getlist("quote_ids[]") + if not action or not quote_ids: - flash(_('Please select an action and at least one quote'), 'error') - return redirect(url_for('quotes.list_quotes')) - + flash(_("Please select an action and at least one quote"), "error") + return redirect(url_for("quotes.list_quotes")) + try: quote_ids = [int(qid) for qid in quote_ids] except ValueError: - flash(_('Invalid quote IDs'), 'error') - return redirect(url_for('quotes.list_quotes')) - + flash(_("Invalid quote IDs"), "error") + return redirect(url_for("quotes.list_quotes")) + # Get quotes (with permission check) quotes = Quote.query.filter(Quote.id.in_(quote_ids)).all() if not current_user.is_admin: quotes = [q for q in quotes if q.created_by == current_user.id] - + if not quotes: - flash(_('No quotes found or you do not have permission'), 'error') - return redirect(url_for('quotes.list_quotes')) - + flash(_("No quotes found or you do not have permission"), "error") + return redirect(url_for("quotes.list_quotes")) + success_count = 0 error_count = 0 - - if action == 'duplicate': + + if action == "duplicate": from datetime import timedelta from app.utils.timezone import local_now - + for quote in quotes: try: new_quote_number = Quote.generate_quote_number() new_valid_until = local_now().date() + timedelta(days=30) if quote.valid_until else None - + new_quote = Quote( quote_number=new_quote_number, client_id=quote.client_id, title=quote.title, description=quote.description, - status='draft', + status="draft", valid_until=new_valid_until, notes=quote.notes, terms=quote.terms, @@ -1425,11 +1455,11 @@ def bulk_action(): discount_amount=quote.discount_amount, discount_reason=quote.discount_reason, coupon_code=quote.coupon_code, - approval_status='not_required' + approval_status="not_required", ) db.session.add(new_quote) db.session.flush() - + # Duplicate items for item in quote.items: new_item = QuoteItem( @@ -1437,27 +1467,27 @@ def bulk_action(): description=item.description, quantity=item.quantity, unit_price=item.unit_price, - unit=item.unit + unit=item.unit, ) db.session.add(new_item) - + new_quote.calculate_totals() success_count += 1 except Exception as e: current_app.logger.error(f"Error duplicating quote {quote.id}: {e}") error_count += 1 - - if safe_commit('bulk_duplicate_quotes', {'count': success_count}): - flash(_('Duplicated %(count)d quote(s)', count=success_count), 'success') + + if safe_commit("bulk_duplicate_quotes", {"count": success_count}): + flash(_("Duplicated %(count)d quote(s)", count=success_count), "success") if error_count > 0: - flash(_('Failed to duplicate %(count)d quote(s)', count=error_count), 'error') + flash(_("Failed to duplicate %(count)d quote(s)", count=error_count), "error") else: - flash(_('Error duplicating quotes'), 'error') - - elif action == 'mark_sent': + flash(_("Error duplicating quotes"), "error") + + elif action == "mark_sent": for quote in quotes: try: - if quote.status == 'draft' and quote.approval_status != 'pending': + if quote.status == "draft" and quote.approval_status != "pending": quote.send() success_count += 1 else: @@ -1465,19 +1495,19 @@ def bulk_action(): except Exception as e: current_app.logger.error(f"Error marking quote {quote.id} as sent: {e}") error_count += 1 - - if safe_commit('bulk_mark_sent', {'count': success_count}): - flash(_('Marked %(count)d quote(s) as sent', count=success_count), 'success') + + if safe_commit("bulk_mark_sent", {"count": success_count}): + flash(_("Marked %(count)d quote(s) as sent", count=success_count), "success") if error_count > 0: - flash(_('Could not mark %(count)d quote(s) as sent', count=error_count), 'error') + flash(_("Could not mark %(count)d quote(s) as sent", count=error_count), "error") else: - flash(_('Error updating quotes'), 'error') - - elif action == 'delete': + flash(_("Error updating quotes"), "error") + + elif action == "delete": for quote in quotes: try: # Check if quote can be deleted - if quote.status in ['draft', 'rejected', 'expired']: + if quote.status in ["draft", "rejected", "expired"]: db.session.delete(quote) success_count += 1 else: @@ -1485,16 +1515,15 @@ def bulk_action(): except Exception as e: current_app.logger.error(f"Error deleting quote {quote.id}: {e}") error_count += 1 - - if safe_commit('bulk_delete_quotes', {'count': success_count}): - flash(_('Deleted %(count)d quote(s)', count=success_count), 'success') + + if safe_commit("bulk_delete_quotes", {"count": success_count}): + flash(_("Deleted %(count)d quote(s)", count=success_count), "success") if error_count > 0: - flash(_('Could not delete %(count)d quote(s) (may be in use)', count=error_count), 'error') + flash(_("Could not delete %(count)d quote(s) (may be in use)", count=error_count), "error") else: - flash(_('Error deleting quotes'), 'error') - + flash(_("Error deleting quotes"), "error") + else: - flash(_('Invalid action'), 'error') - - return redirect(url_for('quotes.list_quotes')) + flash(_("Invalid action"), "error") + return redirect(url_for("quotes.list_quotes")) diff --git a/app/routes/recurring_invoices.py b/app/routes/recurring_invoices.py index 96dc69b2..5cee4515 100644 --- a/app/routes/recurring_invoices.py +++ b/app/routes/recurring_invoices.py @@ -8,100 +8,100 @@ import logging from app.utils.db import safe_commit -recurring_invoices_bp = Blueprint('recurring_invoices', __name__) +recurring_invoices_bp = Blueprint("recurring_invoices", __name__) logger = logging.getLogger(__name__) -@recurring_invoices_bp.route('/recurring-invoices') +@recurring_invoices_bp.route("/recurring-invoices") @login_required def list_recurring_invoices(): """List all recurring invoices""" # Get filter parameters - is_active = request.args.get('is_active', '').strip() - + is_active = request.args.get("is_active", "").strip() + # Build query if current_user.is_admin: query = RecurringInvoice.query else: query = RecurringInvoice.query.filter_by(created_by=current_user.id) - + # Apply active filter - if is_active == 'true': + if is_active == "true": query = query.filter_by(is_active=True) - elif is_active == 'false': + elif is_active == "false": query = query.filter_by(is_active=False) - + # Get recurring invoices recurring_invoices = query.order_by(RecurringInvoice.next_run_date.asc()).all() - - return render_template('recurring_invoices/list.html', recurring_invoices=recurring_invoices) + return render_template("recurring_invoices/list.html", recurring_invoices=recurring_invoices) -@recurring_invoices_bp.route('/recurring-invoices/create', methods=['GET', 'POST']) + +@recurring_invoices_bp.route("/recurring-invoices/create", methods=["GET", "POST"]) @login_required def create_recurring_invoice(): """Create a new recurring invoice""" - if request.method == 'POST': + if request.method == "POST": # Get form data - name = request.form.get('name', '').strip() - project_id = request.form.get('project_id', type=int) - client_id = request.form.get('client_id', type=int) - frequency = request.form.get('frequency', '').strip() - interval = request.form.get('interval', type=int, default=1) - next_run_date_str = request.form.get('next_run_date', '').strip() - end_date_str = request.form.get('end_date', '').strip() - client_name = request.form.get('client_name', '').strip() - client_email = request.form.get('client_email', '').strip() - client_address = request.form.get('client_address', '').strip() - due_date_days = request.form.get('due_date_days', type=int, default=30) - tax_rate = request.form.get('tax_rate', '0').strip() - notes = request.form.get('notes', '').strip() - terms = request.form.get('terms', '').strip() - auto_send = request.form.get('auto_send') == 'on' - auto_include_time_entries = request.form.get('auto_include_time_entries') != 'off' - + name = request.form.get("name", "").strip() + project_id = request.form.get("project_id", type=int) + client_id = request.form.get("client_id", type=int) + frequency = request.form.get("frequency", "").strip() + interval = request.form.get("interval", type=int, default=1) + next_run_date_str = request.form.get("next_run_date", "").strip() + end_date_str = request.form.get("end_date", "").strip() + client_name = request.form.get("client_name", "").strip() + client_email = request.form.get("client_email", "").strip() + client_address = request.form.get("client_address", "").strip() + due_date_days = request.form.get("due_date_days", type=int, default=30) + tax_rate = request.form.get("tax_rate", "0").strip() + notes = request.form.get("notes", "").strip() + terms = request.form.get("terms", "").strip() + auto_send = request.form.get("auto_send") == "on" + auto_include_time_entries = request.form.get("auto_include_time_entries") != "off" + # Validate required fields if not name or not project_id or not client_id or not frequency or not next_run_date_str: - flash(_('Name, project, client, frequency, and next run date are required'), 'error') - return render_template('recurring_invoices/create.html') - + flash(_("Name, project, client, frequency, and next run date are required"), "error") + return render_template("recurring_invoices/create.html") + try: - next_run_date = datetime.strptime(next_run_date_str, '%Y-%m-%d').date() + next_run_date = datetime.strptime(next_run_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid next run date format'), 'error') - return render_template('recurring_invoices/create.html') - + flash(_("Invalid next run date format"), "error") + return render_template("recurring_invoices/create.html") + end_date = None if end_date_str: try: - end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() + end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid end date format'), 'error') - return render_template('recurring_invoices/create.html') - + flash(_("Invalid end date format"), "error") + return render_template("recurring_invoices/create.html") + try: tax_rate = Decimal(tax_rate) except ValueError: - flash(_('Invalid tax rate format'), 'error') - return render_template('recurring_invoices/create.html') - + flash(_("Invalid tax rate format"), "error") + return render_template("recurring_invoices/create.html") + # Get project and client project = Project.query.get(project_id) client = Client.query.get(client_id) if not project or not client: - flash(_('Selected project or client not found'), 'error') - return render_template('recurring_invoices/create.html') - + flash(_("Selected project or client not found"), "error") + return render_template("recurring_invoices/create.html") + # Get currency from settings settings = Settings.get_settings() - currency_code = settings.currency if settings else 'EUR' - + currency_code = settings.currency if settings else "EUR" + # Use client info if not provided if not client_name: client_name = client.name if not client_email: client_email = client.email - + # Create recurring invoice recurring = RecurringInvoice( name=name, @@ -121,144 +121,146 @@ def create_recurring_invoice(): terms=terms, currency_code=currency_code, auto_send=auto_send, - auto_include_time_entries=auto_include_time_entries + auto_include_time_entries=auto_include_time_entries, ) - + db.session.add(recurring) - if not safe_commit('create_recurring_invoice', {'name': name, 'project_id': project_id}): - flash(_('Could not create recurring invoice due to a database error. Please check server logs.'), 'error') - return render_template('recurring_invoices/create.html') - - flash(f'Recurring invoice "{name}" created successfully', 'success') - return redirect(url_for('recurring_invoices.list_recurring_invoices')) - + if not safe_commit("create_recurring_invoice", {"name": name, "project_id": project_id}): + flash(_("Could not create recurring invoice due to a database error. Please check server logs."), "error") + return render_template("recurring_invoices/create.html") + + flash(f'Recurring invoice "{name}" created successfully', "success") + return redirect(url_for("recurring_invoices.list_recurring_invoices")) + # GET request - show form - projects = Project.query.filter_by(status='active', billable=True).order_by(Project.name).all() - clients = Client.query.filter_by(status='active').order_by(Client.name).all() + projects = Project.query.filter_by(status="active", billable=True).order_by(Project.name).all() + clients = Client.query.filter_by(status="active").order_by(Client.name).all() settings = Settings.get_settings() - + # Set default next run date to tomorrow - default_next_run_date = (datetime.utcnow() + timedelta(days=1)).strftime('%Y-%m-%d') - - return render_template('recurring_invoices/create.html', - projects=projects, - clients=clients, - settings=settings, - default_next_run_date=default_next_run_date) + default_next_run_date = (datetime.utcnow() + timedelta(days=1)).strftime("%Y-%m-%d") + return render_template( + "recurring_invoices/create.html", + projects=projects, + clients=clients, + settings=settings, + default_next_run_date=default_next_run_date, + ) -@recurring_invoices_bp.route('/recurring-invoices/') + +@recurring_invoices_bp.route("/recurring-invoices/") @login_required def view_recurring_invoice(recurring_id): """View recurring invoice details""" recurring = RecurringInvoice.query.get_or_404(recurring_id) - + # Check access permissions if not current_user.is_admin and recurring.created_by != current_user.id: - flash(_('You do not have permission to view this recurring invoice'), 'error') - return redirect(url_for('recurring_invoices.list_recurring_invoices')) - + flash(_("You do not have permission to view this recurring invoice"), "error") + return redirect(url_for("recurring_invoices.list_recurring_invoices")) + # Get generated invoices generated_invoices = recurring.generated_invoices.order_by(Invoice.created_at.desc()).limit(10).all() - - return render_template('recurring_invoices/view.html', recurring=recurring, generated_invoices=generated_invoices) + + return render_template("recurring_invoices/view.html", recurring=recurring, generated_invoices=generated_invoices) -@recurring_invoices_bp.route('/recurring-invoices//edit', methods=['GET', 'POST']) +@recurring_invoices_bp.route("/recurring-invoices//edit", methods=["GET", "POST"]) @login_required def edit_recurring_invoice(recurring_id): """Edit recurring invoice""" recurring = RecurringInvoice.query.get_or_404(recurring_id) - + # Check access permissions if not current_user.is_admin and recurring.created_by != current_user.id: - flash(_('You do not have permission to edit this recurring invoice'), 'error') - return redirect(url_for('recurring_invoices.list_recurring_invoices')) - - if request.method == 'POST': + flash(_("You do not have permission to edit this recurring invoice"), "error") + return redirect(url_for("recurring_invoices.list_recurring_invoices")) + + if request.method == "POST": # Update recurring invoice - recurring.name = request.form.get('name', '').strip() - recurring.frequency = request.form.get('frequency', '').strip() - recurring.interval = request.form.get('interval', type=int, default=1) - recurring.next_run_date = datetime.strptime(request.form.get('next_run_date'), '%Y-%m-%d').date() - - end_date_str = request.form.get('end_date', '').strip() - recurring.end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None - - recurring.client_name = request.form.get('client_name', '').strip() - recurring.client_email = request.form.get('client_email', '').strip() - recurring.client_address = request.form.get('client_address', '').strip() - recurring.due_date_days = request.form.get('due_date_days', type=int, default=30) - recurring.tax_rate = Decimal(request.form.get('tax_rate', '0')) - recurring.notes = request.form.get('notes', '').strip() - recurring.terms = request.form.get('terms', '').strip() - recurring.auto_send = request.form.get('auto_send') == 'on' - recurring.auto_include_time_entries = request.form.get('auto_include_time_entries') != 'off' - recurring.is_active = request.form.get('is_active') == 'on' - - if not safe_commit('edit_recurring_invoice', {'recurring_id': recurring.id}): - flash(_('Could not update recurring invoice due to a database error. Please check server logs.'), 'error') - return render_template('recurring_invoices/edit.html', recurring=recurring, projects=Project.query.filter_by(status='active').order_by(Project.name).all(), clients=Client.query.filter_by(status='active').order_by(Client.name).all()) - - flash(_('Recurring invoice updated successfully'), 'success') - return redirect(url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id)) - + recurring.name = request.form.get("name", "").strip() + recurring.frequency = request.form.get("frequency", "").strip() + recurring.interval = request.form.get("interval", type=int, default=1) + recurring.next_run_date = datetime.strptime(request.form.get("next_run_date"), "%Y-%m-%d").date() + + end_date_str = request.form.get("end_date", "").strip() + recurring.end_date = datetime.strptime(end_date_str, "%Y-%m-%d").date() if end_date_str else None + + recurring.client_name = request.form.get("client_name", "").strip() + recurring.client_email = request.form.get("client_email", "").strip() + recurring.client_address = request.form.get("client_address", "").strip() + recurring.due_date_days = request.form.get("due_date_days", type=int, default=30) + recurring.tax_rate = Decimal(request.form.get("tax_rate", "0")) + recurring.notes = request.form.get("notes", "").strip() + recurring.terms = request.form.get("terms", "").strip() + recurring.auto_send = request.form.get("auto_send") == "on" + recurring.auto_include_time_entries = request.form.get("auto_include_time_entries") != "off" + recurring.is_active = request.form.get("is_active") == "on" + + if not safe_commit("edit_recurring_invoice", {"recurring_id": recurring.id}): + flash(_("Could not update recurring invoice due to a database error. Please check server logs."), "error") + return render_template( + "recurring_invoices/edit.html", + recurring=recurring, + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), + clients=Client.query.filter_by(status="active").order_by(Client.name).all(), + ) + + flash(_("Recurring invoice updated successfully"), "success") + return redirect(url_for("recurring_invoices.view_recurring_invoice", recurring_id=recurring.id)) + # GET request - show edit form - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - clients = Client.query.filter_by(status='active').order_by(Client.name).all() - return render_template('recurring_invoices/edit.html', recurring=recurring, projects=projects, clients=clients) + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + clients = Client.query.filter_by(status="active").order_by(Client.name).all() + return render_template("recurring_invoices/edit.html", recurring=recurring, projects=projects, clients=clients) -@recurring_invoices_bp.route('/recurring-invoices//delete', methods=['POST']) +@recurring_invoices_bp.route("/recurring-invoices//delete", methods=["POST"]) @login_required def delete_recurring_invoice(recurring_id): """Delete recurring invoice""" recurring = RecurringInvoice.query.get_or_404(recurring_id) - + # Check access permissions if not current_user.is_admin and recurring.created_by != current_user.id: - flash(_('You do not have permission to delete this recurring invoice'), 'error') - return redirect(url_for('recurring_invoices.list_recurring_invoices')) - + flash(_("You do not have permission to delete this recurring invoice"), "error") + return redirect(url_for("recurring_invoices.list_recurring_invoices")) + name = recurring.name db.session.delete(recurring) - if not safe_commit('delete_recurring_invoice', {'recurring_id': recurring.id}): - flash(_('Could not delete recurring invoice due to a database error. Please check server logs.'), 'error') - return redirect(url_for('recurring_invoices.list_recurring_invoices')) - - flash(f'Recurring invoice "{name}" deleted successfully', 'success') - return redirect(url_for('recurring_invoices.list_recurring_invoices')) + if not safe_commit("delete_recurring_invoice", {"recurring_id": recurring.id}): + flash(_("Could not delete recurring invoice due to a database error. Please check server logs."), "error") + return redirect(url_for("recurring_invoices.list_recurring_invoices")) + flash(f'Recurring invoice "{name}" deleted successfully', "success") + return redirect(url_for("recurring_invoices.list_recurring_invoices")) -@recurring_invoices_bp.route('/recurring-invoices//generate', methods=['POST']) + +@recurring_invoices_bp.route("/recurring-invoices//generate", methods=["POST"]) @login_required def generate_invoice_now(recurring_id): """Manually generate an invoice from a recurring template""" recurring = RecurringInvoice.query.get_or_404(recurring_id) - + # Check access permissions if not current_user.is_admin and recurring.created_by != current_user.id: - return jsonify({'error': 'Permission denied'}), 403 - + return jsonify({"error": "Permission denied"}), 403 + try: # Temporarily set next_run_date to today to allow generation original_next_run_date = recurring.next_run_date recurring.next_run_date = datetime.utcnow().date() - + invoice = recurring.generate_invoice() if invoice: db.session.commit() - flash(f'Invoice {invoice.invoice_number} generated successfully', 'success') - return jsonify({ - 'success': True, - 'invoice_id': invoice.id, - 'invoice_number': invoice.invoice_number - }) + flash(f"Invoice {invoice.invoice_number} generated successfully", "success") + return jsonify({"success": True, "invoice_id": invoice.id, "invoice_number": invoice.invoice_number}) else: recurring.next_run_date = original_next_run_date - return jsonify({'error': 'Failed to generate invoice'}), 400 - + return jsonify({"error": "Failed to generate invoice"}), 400 + except Exception as e: logger.error(f"Error generating invoice from recurring template: {e}") - return jsonify({'error': str(e)}), 500 - + return jsonify({"error": str(e)}), 500 diff --git a/app/routes/reports.py b/app/routes/reports.py index 3faeb57d..070478e8 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -2,7 +2,19 @@ from flask_login import login_required, current_user from flask_babel import _ from app import db, log_event, track_event -from app.models import User, Project, TimeEntry, Settings, Task, ProjectCost, Client, Payment, Invoice, ReportEmailSchedule, SavedReportView +from app.models import ( + User, + Project, + TimeEntry, + Settings, + Task, + ProjectCost, + Client, + Payment, + Invoice, + ReportEmailSchedule, + SavedReportView, +) from app.services.scheduled_report_service import ScheduledReportService from datetime import datetime, timedelta from sqlalchemy import or_, func @@ -11,46 +23,41 @@ import pytz import time from app.utils.excel_export import create_time_entries_excel, create_project_report_excel -from app.utils.posthog_monitoring import ( - track_error, - track_export_performance, - track_validation_error -) +from app.utils.posthog_monitoring import track_error, track_export_performance, track_validation_error + +reports_bp = Blueprint("reports", __name__) -reports_bp = Blueprint('reports', __name__) -@reports_bp.route('/reports') +@reports_bp.route("/reports") @login_required def reports(): """Main reports page - REFACTORED to use service layer with optimized queries""" from app.services import ReportingService - + # Use service layer to get reports summary (optimized queries) reporting_service = ReportingService() - result = reporting_service.get_reports_summary( - user_id=current_user.id, - is_admin=current_user.is_admin - ) - + result = reporting_service.get_reports_summary(user_id=current_user.id, is_admin=current_user.is_admin) + # Track report access log_event("report.viewed", user_id=current_user.id, report_type="summary") track_event(current_user.id, "report.viewed", {"report_type": "summary"}) - + return render_template( - 'reports/index.html', - summary=result['summary'], - recent_entries=result['recent_entries'], - comparison=result['comparison'] + "reports/index.html", + summary=result["summary"], + recent_entries=result["recent_entries"], + comparison=result["comparison"], ) -@reports_bp.route('/reports/comparison') + +@reports_bp.route("/reports/comparison") @login_required def comparison_view(): """Get comparison data for reports""" - period = request.args.get('period', 'month') + period = request.args.get("period", "month") now = datetime.utcnow() - - if period == 'month': + + if period == "month": # This month vs last month this_period_start = datetime(now.year, now.month, 1) last_period_start = (this_period_start - timedelta(days=1)).replace(day=1) @@ -60,76 +67,71 @@ def comparison_view(): this_period_start = datetime(now.year, 1, 1) last_period_start = datetime(now.year - 1, 1, 1) last_period_end = datetime(now.year, 1, 1) - timedelta(seconds=1) - + # Get hours for current period current_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= this_period_start, - TimeEntry.start_time <= now + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= this_period_start, TimeEntry.start_time <= now ) if not current_user.is_admin: current_query = current_query.filter(TimeEntry.user_id == current_user.id) current_seconds = current_query.scalar() or 0 - + # Get hours for previous period previous_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= last_period_start, - TimeEntry.start_time <= last_period_end + TimeEntry.start_time <= last_period_end, ) if not current_user.is_admin: previous_query = previous_query.filter(TimeEntry.user_id == current_user.id) previous_seconds = previous_query.scalar() or 0 - + current_hours = round(current_seconds / 3600, 2) previous_hours = round(previous_seconds / 3600, 2) change = ((current_hours - previous_hours) / previous_hours * 100) if previous_hours > 0 else 0 - - return jsonify({ - 'current': {'hours': current_hours}, - 'previous': {'hours': previous_hours}, - 'change': round(change, 1) - }) - -@reports_bp.route('/reports/project') + + return jsonify( + {"current": {"hours": current_hours}, "previous": {"hours": previous_hours}, "change": round(change, 1)} + ) + + +@reports_bp.route("/reports/project") @login_required def project_report(): """Project-based time report""" - project_id = request.args.get('project_id', type=int) - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') - user_id = request.args.get('user_id', type=int) - + project_id = request.args.get("project_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + user_id = request.args.get("user_id", type=int) + # Get projects for filter - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() users = User.query.filter_by(is_active=True).order_by(User.username).all() - + # Parse dates if not start_date: - start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") if not end_date: - end_date = datetime.utcnow().strftime('%Y-%m-%d') - + end_date = datetime.utcnow().strftime("%Y-%m-%d") + try: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) except ValueError: - flash(_('Invalid date format'), 'error') - return render_template('reports/project_report.html', projects=projects, users=users) - + flash(_("Invalid date format"), "error") + return render_template("reports/project_report.html", projects=projects, users=users) + # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt ) - + if project_id: query = query.filter(TimeEntry.project_id == project_id) - + if user_id: query = query.filter(TimeEntry.user_id == user_id) - + entries = query.order_by(TimeEntry.start_time.desc()).all() # Aggregate by project for template expectations @@ -140,52 +142,52 @@ def project_report(): continue if project.id not in projects_map: projects_map[project.id] = { - 'id': project.id, - 'name': project.name, - 'client': project.client, - 'description': project.description, - 'billable': project.billable, - 'hourly_rate': float(project.hourly_rate) if project.hourly_rate else None, - 'total_hours': 0.0, - 'billable_hours': 0.0, - 'billable_amount': 0.0, - 'total_costs': 0.0, - 'billable_costs': 0.0, - 'total_value': 0.0, - 'user_totals': {} + "id": project.id, + "name": project.name, + "client": project.client, + "description": project.description, + "billable": project.billable, + "hourly_rate": float(project.hourly_rate) if project.hourly_rate else None, + "total_hours": 0.0, + "billable_hours": 0.0, + "billable_amount": 0.0, + "total_costs": 0.0, + "billable_costs": 0.0, + "total_value": 0.0, + "user_totals": {}, } agg = projects_map[project.id] hours = entry.duration_hours - agg['total_hours'] += hours + agg["total_hours"] += hours if entry.billable and project.billable: - agg['billable_hours'] += hours + agg["billable_hours"] += hours if project.hourly_rate: - agg['billable_amount'] += hours * float(project.hourly_rate) + agg["billable_amount"] += hours * float(project.hourly_rate) # per-user totals - username = entry.user.display_name if entry.user else 'Unknown' - agg['user_totals'][username] = agg['user_totals'].get(username, 0.0) + hours - + username = entry.user.display_name if entry.user else "Unknown" + agg["user_totals"][username] = agg["user_totals"].get(username, 0.0) + hours + # Add project costs to the aggregated data for project_id, agg in projects_map.items(): # Get costs for this project within the date range costs_query = ProjectCost.query.filter( ProjectCost.project_id == project_id, ProjectCost.cost_date >= start_dt.date(), - ProjectCost.cost_date <= end_dt.date() + ProjectCost.cost_date <= end_dt.date(), ) - + if user_id: costs_query = costs_query.filter(ProjectCost.user_id == user_id) - + costs = costs_query.all() - + for cost in costs: - agg['total_costs'] += float(cost.amount) + agg["total_costs"] += float(cost.amount) if cost.billable: - agg['billable_costs'] += float(cost.amount) - + agg["billable_costs"] += float(cost.amount) + # Calculate total project value (billable hours + billable costs) - agg['total_value'] = agg['billable_amount'] + agg['billable_costs'] + agg["total_value"] = agg["billable_amount"] + agg["billable_costs"] # Finalize structures projects_data = [] @@ -196,85 +198,85 @@ def project_report(): total_billable_costs = 0.0 total_project_value = 0.0 for agg in projects_map.values(): - total_hours += agg['total_hours'] - billable_hours += agg['billable_hours'] - total_billable_amount += agg['billable_amount'] - total_costs += agg['total_costs'] - total_billable_costs += agg['billable_costs'] - total_project_value += agg['total_value'] - agg['total_hours'] = round(agg['total_hours'], 1) - agg['billable_hours'] = round(agg['billable_hours'], 1) - agg['billable_amount'] = round(agg['billable_amount'], 2) - agg['total_costs'] = round(agg['total_costs'], 2) - agg['billable_costs'] = round(agg['billable_costs'], 2) - agg['total_value'] = round(agg['total_value'], 2) - agg['user_totals'] = [ - {'username': username, 'hours': round(hours, 1)} - for username, hours in agg['user_totals'].items() + total_hours += agg["total_hours"] + billable_hours += agg["billable_hours"] + total_billable_amount += agg["billable_amount"] + total_costs += agg["total_costs"] + total_billable_costs += agg["billable_costs"] + total_project_value += agg["total_value"] + agg["total_hours"] = round(agg["total_hours"], 1) + agg["billable_hours"] = round(agg["billable_hours"], 1) + agg["billable_amount"] = round(agg["billable_amount"], 2) + agg["total_costs"] = round(agg["total_costs"], 2) + agg["billable_costs"] = round(agg["billable_costs"], 2) + agg["total_value"] = round(agg["total_value"], 2) + agg["user_totals"] = [ + {"username": username, "hours": round(hours, 1)} for username, hours in agg["user_totals"].items() ] projects_data.append(agg) # Summary section expected by template summary = { - 'total_hours': round(total_hours, 1), - 'billable_hours': round(billable_hours, 1), - 'total_billable_amount': round(total_billable_amount, 2), - 'total_costs': round(total_costs, 2), - 'total_billable_costs': round(total_billable_costs, 2), - 'total_project_value': round(total_project_value, 2), - 'projects_count': len(projects_data), + "total_hours": round(total_hours, 1), + "billable_hours": round(billable_hours, 1), + "total_billable_amount": round(total_billable_amount, 2), + "total_costs": round(total_costs, 2), + "total_billable_costs": round(total_billable_costs, 2), + "total_project_value": round(total_project_value, 2), + "projects_count": len(projects_data), } - return render_template('reports/project_report.html', - projects=projects, - users=users, - entries=entries, - projects_data=projects_data, - summary=summary, - start_date=start_date, - end_date=end_date, - selected_project=project_id, - selected_user=user_id) - -@reports_bp.route('/reports/user') + return render_template( + "reports/project_report.html", + projects=projects, + users=users, + entries=entries, + projects_data=projects_data, + summary=summary, + start_date=start_date, + end_date=end_date, + selected_project=project_id, + selected_user=user_id, + ) + + +@reports_bp.route("/reports/user") @login_required def user_report(): """User-based time report""" - user_id = request.args.get('user_id', type=int) - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') - project_id = request.args.get('project_id', type=int) - + user_id = request.args.get("user_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + project_id = request.args.get("project_id", type=int) + # Get users for filter users = User.query.filter_by(is_active=True).order_by(User.username).all() - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Parse dates if not start_date: - start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") if not end_date: - end_date = datetime.utcnow().strftime('%Y-%m-%d') - + end_date = datetime.utcnow().strftime("%Y-%m-%d") + try: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) except ValueError: - flash(_('Invalid date format'), 'error') - return render_template('reports/user_report.html', users=users, projects=projects) - + flash(_("Invalid date format"), "error") + return render_template("reports/user_report.html", users=users, projects=projects) + # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt ) - + if user_id: query = query.filter(TimeEntry.user_id == user_id) - + if project_id: query = query.filter(TimeEntry.project_id == project_id) - + entries = query.order_by(TimeEntry.start_time.desc()).all() # Calculate totals @@ -290,51 +292,51 @@ def user_report(): projects_set.add(entry.project.id) if entry.user: users_set.add(entry.user.id) - username = entry.user.display_name if entry.user else 'Unknown' + username = entry.user.display_name if entry.user else "Unknown" if username not in user_totals: user_totals[username] = { - 'hours': 0, - 'billable_hours': 0, - 'entries': [], - 'user_obj': entry.user # Store user object for overtime calculation + "hours": 0, + "billable_hours": 0, + "entries": [], + "user_obj": entry.user, # Store user object for overtime calculation } - user_totals[username]['hours'] += entry.duration_hours + user_totals[username]["hours"] += entry.duration_hours if entry.billable: - user_totals[username]['billable_hours'] += entry.duration_hours - user_totals[username]['entries'].append(entry) + user_totals[username]["billable_hours"] += entry.duration_hours + user_totals[username]["entries"].append(entry) # Calculate overtime for each user from app.utils.overtime import calculate_period_overtime + for username, data in user_totals.items(): - if data['user_obj']: - overtime_data = calculate_period_overtime( - data['user_obj'], - start_dt.date(), - end_dt.date() - ) - data['regular_hours'] = overtime_data['regular_hours'] - data['overtime_hours'] = overtime_data['overtime_hours'] - data['days_with_overtime'] = overtime_data['days_with_overtime'] + if data["user_obj"]: + overtime_data = calculate_period_overtime(data["user_obj"], start_dt.date(), end_dt.date()) + data["regular_hours"] = overtime_data["regular_hours"] + data["overtime_hours"] = overtime_data["overtime_hours"] + data["days_with_overtime"] = overtime_data["days_with_overtime"] summary = { - 'total_hours': round(total_hours, 1), - 'billable_hours': round(billable_hours, 1), - 'users_count': len(users_set), - 'projects_count': len(projects_set), + "total_hours": round(total_hours, 1), + "billable_hours": round(billable_hours, 1), + "users_count": len(users_set), + "projects_count": len(projects_set), } - return render_template('reports/user_report.html', - users=users, - projects=projects, - entries=entries, - user_totals=user_totals, - summary=summary, - start_date=start_date, - end_date=end_date, - selected_user=user_id, - selected_project=project_id) - -@reports_bp.route('/reports/export/form') + return render_template( + "reports/user_report.html", + users=users, + projects=projects, + entries=entries, + user_totals=user_totals, + summary=summary, + start_date=start_date, + end_date=end_date, + selected_user=user_id, + selected_project=project_id, + ) + + +@reports_bp.route("/reports/export/form") @login_required def export_form(): """Display CSV export form with filter options""" @@ -342,238 +344,249 @@ def export_form(): users = [] if current_user.is_admin: users = User.query.filter_by(is_active=True).order_by(User.username).all() - + # Get all active projects - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Get all active clients - clients = Client.query.filter_by(status='active').order_by(Client.name).all() - + clients = Client.query.filter_by(status="active").order_by(Client.name).all() + # Set default date range (last 30 days) - default_end_date = datetime.utcnow().strftime('%Y-%m-%d') - default_start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') - - return render_template('reports/export_form.html', - users=users, - projects=projects, - clients=clients, - default_start_date=default_start_date, - default_end_date=default_end_date) - -@reports_bp.route('/reports/export/csv') + default_end_date = datetime.utcnow().strftime("%Y-%m-%d") + default_start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") + + return render_template( + "reports/export_form.html", + users=users, + projects=projects, + clients=clients, + default_start_date=default_start_date, + default_end_date=default_end_date, + ) + + +@reports_bp.route("/reports/export/csv") @login_required def export_csv(): """Export time entries as CSV with enhanced filters""" start_time = time.time() # Start performance tracking - + # Get all filter parameters - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') - user_id = request.args.get('user_id', type=int) - project_id = request.args.get('project_id', type=int) - task_id = request.args.get('task_id', type=int) - client_id = request.args.get('client_id', type=int) - billable = request.args.get('billable') # 'yes', 'no', or 'all' - source = request.args.get('source') # 'manual', 'auto', or 'all' - tags = request.args.get('tags', '').strip() - + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + user_id = request.args.get("user_id", type=int) + project_id = request.args.get("project_id", type=int) + task_id = request.args.get("task_id", type=int) + client_id = request.args.get("client_id", type=int) + billable = request.args.get("billable") # 'yes', 'no', or 'all' + source = request.args.get("source") # 'manual', 'auto', or 'all' + tags = request.args.get("tags", "").strip() + # Parse dates if not start_date: - start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") if not end_date: - end_date = datetime.utcnow().strftime('%Y-%m-%d') - + end_date = datetime.utcnow().strftime("%Y-%m-%d") + try: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) except ValueError: track_validation_error( current_user.id, "date_range", "Invalid date format for CSV export", - {"start_date": start_date, "end_date": end_date} + {"start_date": start_date, "end_date": end_date}, ) - flash(_('Invalid date format'), 'error') - return redirect(url_for('reports.reports')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("reports.reports")) + # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt ) - + if user_id: query = query.filter(TimeEntry.user_id == user_id) - + if project_id: query = query.filter(TimeEntry.project_id == project_id) - + entries = query.order_by(TimeEntry.start_time.desc()).all() - + # Get settings for delimiter settings = Settings.get_settings() delimiter = settings.export_delimiter - + # Create CSV output = io.StringIO() writer = csv.writer(output, delimiter=delimiter) - + # Write header with task column - writer.writerow([ - 'ID', 'User', 'Project', 'Client', 'Task', 'Start Time', 'End Time', - 'Duration (hours)', 'Duration (formatted)', 'Notes', 'Tags', - 'Source', 'Billable', 'Created At', 'Updated At' - ]) - + writer.writerow( + [ + "ID", + "User", + "Project", + "Client", + "Task", + "Start Time", + "End Time", + "Duration (hours)", + "Duration (formatted)", + "Notes", + "Tags", + "Source", + "Billable", + "Created At", + "Updated At", + ] + ) + # Write data for entry in entries: - writer.writerow([ - entry.id, - entry.user.display_name, - entry.project.name, - entry.project.client, - entry.task.name if entry.task else '', - entry.start_time.isoformat(), - entry.end_time.isoformat() if entry.end_time else '', - entry.duration_hours, - entry.duration_formatted, - entry.notes or '', - entry.tags or '', - entry.source, - 'Yes' if entry.billable else 'No', - entry.created_at.isoformat(), - entry.updated_at.isoformat() if entry.updated_at else '' - ]) - + writer.writerow( + [ + entry.id, + entry.user.display_name, + entry.project.name, + entry.project.client, + entry.task.name if entry.task else "", + entry.start_time.isoformat(), + entry.end_time.isoformat() if entry.end_time else "", + entry.duration_hours, + entry.duration_formatted, + entry.notes or "", + entry.tags or "", + entry.source, + "Yes" if entry.billable else "No", + entry.created_at.isoformat(), + entry.updated_at.isoformat() if entry.updated_at else "", + ] + ) + output.seek(0) - + # Create filename with filters indication - filename_parts = [f'timetracker_export_{start_date}_to_{end_date}'] + filename_parts = [f"timetracker_export_{start_date}_to_{end_date}"] if project_id: - filename_parts.append('project') + filename_parts.append("project") if client_id: - filename_parts.append('client') + filename_parts.append("client") if task_id: - filename_parts.append('task') - filename = '_'.join(filename_parts) + '.csv' - + filename_parts.append("task") + filename = "_".join(filename_parts) + ".csv" + # Track CSV export event with enhanced metadata - log_event("export.csv", - user_id=current_user.id, - export_type="time_entries", - num_rows=len(entries), - date_range_days=(end_dt - start_dt).days, - filters_applied={ - 'user_id': user_id, - 'project_id': project_id, - 'task_id': task_id, - 'client_id': client_id, - 'billable': billable, - 'source': source, - 'tags': tags - }) - track_event(current_user.id, "export.csv", { - "export_type": "time_entries", - "num_rows": len(entries), - "date_range_days": (end_dt - start_dt).days, - "has_project_filter": project_id is not None, - "has_client_filter": client_id is not None, - "has_task_filter": task_id is not None, - "has_billable_filter": billable is not None and billable != 'all', - "has_source_filter": source is not None and source != 'all', - "has_tags_filter": bool(tags) - }) - + log_event( + "export.csv", + user_id=current_user.id, + export_type="time_entries", + num_rows=len(entries), + date_range_days=(end_dt - start_dt).days, + filters_applied={ + "user_id": user_id, + "project_id": project_id, + "task_id": task_id, + "client_id": client_id, + "billable": billable, + "source": source, + "tags": tags, + }, + ) + track_event( + current_user.id, + "export.csv", + { + "export_type": "time_entries", + "num_rows": len(entries), + "date_range_days": (end_dt - start_dt).days, + "has_project_filter": project_id is not None, + "has_client_filter": client_id is not None, + "has_task_filter": task_id is not None, + "has_billable_filter": billable is not None and billable != "all", + "has_source_filter": source is not None and source != "all", + "has_tags_filter": bool(tags), + }, + ) + # Track performance try: duration_ms = (time.time() - start_time) * 1000 - csv_content = output.getvalue().encode('utf-8') + csv_content = output.getvalue().encode("utf-8") track_export_performance( - current_user.id, - "csv", - row_count=len(entries), - duration_ms=duration_ms, - file_size_bytes=len(csv_content) + current_user.id, "csv", row_count=len(entries), duration_ms=duration_ms, file_size_bytes=len(csv_content) ) except Exception as e: # Don't let tracking errors break the export pass - - return send_file( - io.BytesIO(csv_content), - mimetype='text/csv', - as_attachment=True, - download_name=filename - ) -@reports_bp.route('/reports/summary') + return send_file(io.BytesIO(csv_content), mimetype="text/csv", as_attachment=True, download_name=filename) + + +@reports_bp.route("/reports/summary") @login_required def summary_report(): """Summary report with key metrics""" # Get date range end_date = datetime.utcnow() start_date = end_date - timedelta(days=30) - + # Get total hours for different periods today_hours = TimeEntry.get_total_hours_for_period( - start_date=end_date.date(), - user_id=current_user.id if not current_user.is_admin else None + start_date=end_date.date(), user_id=current_user.id if not current_user.is_admin else None ) - + week_hours = TimeEntry.get_total_hours_for_period( - start_date=end_date.date() - timedelta(days=7), - user_id=current_user.id if not current_user.is_admin else None + start_date=end_date.date() - timedelta(days=7), user_id=current_user.id if not current_user.is_admin else None ) - + month_hours = TimeEntry.get_total_hours_for_period( - start_date=start_date.date(), - user_id=current_user.id if not current_user.is_admin else None + start_date=start_date.date(), user_id=current_user.id if not current_user.is_admin else None ) - + # Get top projects if current_user.is_admin: # For admins, show all projects - projects = Project.query.filter_by(status='active').all() + projects = Project.query.filter_by(status="active").all() else: # For users, show only their projects - project_ids = db.session.query(TimeEntry.project_id).filter( - TimeEntry.user_id == current_user.id - ).distinct().all() + project_ids = ( + db.session.query(TimeEntry.project_id).filter(TimeEntry.user_id == current_user.id).distinct().all() + ) project_ids = [pid[0] for pid in project_ids] projects = Project.query.filter(Project.id.in_(project_ids)).all() - + # Sort projects by total hours project_stats = [] for project in projects: hours = TimeEntry.get_total_hours_for_period( start_date=start_date.date(), project_id=project.id, - user_id=current_user.id if not current_user.is_admin else None + user_id=current_user.id if not current_user.is_admin else None, ) if hours > 0: - project_stats.append({ - 'project': project, - 'hours': hours - }) - - project_stats.sort(key=lambda x: x['hours'], reverse=True) - - return render_template('reports/summary.html', - today_hours=today_hours, - week_hours=week_hours, - month_hours=month_hours, - project_stats=project_stats[:10]) # Top 10 projects - - -@reports_bp.route('/reports/tasks') + project_stats.append({"project": project, "hours": hours}) + + project_stats.sort(key=lambda x: x["hours"], reverse=True) + + return render_template( + "reports/summary.html", + today_hours=today_hours, + week_hours=week_hours, + month_hours=month_hours, + project_stats=project_stats[:10], + ) # Top 10 projects + + +@reports_bp.route("/reports/tasks") @login_required def task_report(): """Report of finished tasks within a project, including hours spent per task""" - project_id = request.args.get('project_id', type=int) - user_id = request.args.get('user_id', type=int) - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') + project_id = request.args.get("project_id", type=int) + user_id = request.args.get("user_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") # Filters data projects = Project.query.order_by(Project.name).all() @@ -581,19 +594,19 @@ def task_report(): # Default date range: last 30 days if not start_date: - start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") if not end_date: - end_date = datetime.utcnow().strftime('%Y-%m-%d') + end_date = datetime.utcnow().strftime("%Y-%m-%d") try: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) except ValueError: - flash(_('Invalid date format'), 'error') - return render_template('reports/task_report.html', projects=projects, users=users) + flash(_("Invalid date format"), "error") + return render_template("reports/task_report.html", projects=projects, users=users) # Base tasks query: finished tasks - tasks_query = Task.query.filter(Task.status == 'done') + tasks_query = Task.query.filter(Task.status == "done") if project_id: tasks_query = tasks_query.filter(Task.project_id == project_id) @@ -616,7 +629,7 @@ def task_report(): TimeEntry.task_id == task.id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.start_time <= end_dt, ) if project_id: te_query = te_query.filter(TimeEntry.project_id == project_id) @@ -627,22 +640,24 @@ def task_report(): hours = sum(e.duration_hours for e in entries) total_hours += hours - task_rows.append({ - 'task': task, - 'project': task.project, - 'assignee': task.assigned_user, - 'completed_at': task.completed_at, - 'hours': round(hours, 2), - 'entries_count': len(entries), - }) + task_rows.append( + { + "task": task, + "project": task.project, + "assignee": task.assigned_user, + "completed_at": task.completed_at, + "hours": round(hours, 2), + "entries_count": len(entries), + } + ) summary = { - 'tasks_count': len(task_rows), - 'total_hours': round(total_hours, 2), + "tasks_count": len(task_rows), + "total_hours": round(total_hours, 2), } return render_template( - 'reports/task_report.html', + "reports/task_report.html", projects=projects, users=users, tasks=task_rows, @@ -654,103 +669,101 @@ def task_report(): ) -@reports_bp.route('/reports/export/excel') +@reports_bp.route("/reports/export/excel") @login_required def export_excel(): """Export time entries as Excel file""" - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') - user_id = request.args.get('user_id', type=int) - project_id = request.args.get('project_id', type=int) - + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + user_id = request.args.get("user_id", type=int) + project_id = request.args.get("project_id", type=int) + # Parse dates if not start_date: - start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") if not end_date: - end_date = datetime.utcnow().strftime('%Y-%m-%d') - + end_date = datetime.utcnow().strftime("%Y-%m-%d") + try: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('reports.reports')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("reports.reports")) + # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt ) - + if user_id: query = query.filter(TimeEntry.user_id == user_id) - + if project_id: query = query.filter(TimeEntry.project_id == project_id) - + entries = query.order_by(TimeEntry.start_time.desc()).all() - + # Create Excel file - output, filename = create_time_entries_excel(entries, filename_prefix='timetracker_export') - + output, filename = create_time_entries_excel(entries, filename_prefix="timetracker_export") + # Track Excel export event - log_event("export.excel", - user_id=current_user.id, - export_type="time_entries", - num_rows=len(entries), - date_range_days=(end_dt - start_dt).days) - track_event(current_user.id, "export.excel", { - "export_type": "time_entries", - "num_rows": len(entries), - "date_range_days": (end_dt - start_dt).days - }) - + log_event( + "export.excel", + user_id=current_user.id, + export_type="time_entries", + num_rows=len(entries), + date_range_days=(end_dt - start_dt).days, + ) + track_event( + current_user.id, + "export.excel", + {"export_type": "time_entries", "num_rows": len(entries), "date_range_days": (end_dt - start_dt).days}, + ) + return send_file( output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", as_attachment=True, - download_name=filename + download_name=filename, ) -@reports_bp.route('/reports/project/export/excel') +@reports_bp.route("/reports/project/export/excel") @login_required def export_project_excel(): """Export project report as Excel file""" - project_id = request.args.get('project_id', type=int) - start_date = request.args.get('start_date') - end_date = request.args.get('end_date') - user_id = request.args.get('user_id', type=int) - + project_id = request.args.get("project_id", type=int) + start_date = request.args.get("start_date") + end_date = request.args.get("end_date") + user_id = request.args.get("user_id", type=int) + # Parse dates if not start_date: - start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + start_date = (datetime.utcnow() - timedelta(days=30)).strftime("%Y-%m-%d") if not end_date: - end_date = datetime.utcnow().strftime('%Y-%m-%d') - + end_date = datetime.utcnow().strftime("%Y-%m-%d") + try: - start_dt = datetime.strptime(start_date, '%Y-%m-%d') - end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1) except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('reports.project_report')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("reports.project_report")) + # Get time entries query = TimeEntry.query.filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_dt, - TimeEntry.start_time <= end_dt + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt ) - + if project_id: query = query.filter(TimeEntry.project_id == project_id) - + if user_id: query = query.filter(TimeEntry.user_id == user_id) - + entries = query.all() - + # Aggregate by project projects_map = {} for entry in entries: @@ -759,41 +772,35 @@ def export_project_excel(): continue if project.id not in projects_map: projects_map[project.id] = { - 'name': project.name, - 'client': project.client if project.client else '', - 'total_hours': 0, - 'billable_hours': 0, - 'hourly_rate': float(project.hourly_rate) if project.hourly_rate else 0, - 'billable_amount': 0, - 'total_costs': 0, - 'total_value': 0, + "name": project.name, + "client": project.client if project.client else "", + "total_hours": 0, + "billable_hours": 0, + "hourly_rate": float(project.hourly_rate) if project.hourly_rate else 0, + "billable_amount": 0, + "total_costs": 0, + "total_value": 0, } agg = projects_map[project.id] hours = entry.duration_hours - agg['total_hours'] += hours + agg["total_hours"] += hours if entry.billable and project.billable: - agg['billable_hours'] += hours + agg["billable_hours"] += hours if project.hourly_rate: - agg['billable_amount'] += hours * float(project.hourly_rate) - + agg["billable_amount"] += hours * float(project.hourly_rate) + projects_data = list(projects_map.values()) - + # Create Excel file output, filename = create_project_report_excel(projects_data, start_date, end_date) - + # Track event - log_event("export.excel", - user_id=current_user.id, - export_type="project_report", - num_projects=len(projects_data)) - track_event(current_user.id, "export.excel", { - "export_type": "project_report", - "num_projects": len(projects_data) - }) - + log_event("export.excel", user_id=current_user.id, export_type="project_report", num_projects=len(projects_data)) + track_event(current_user.id, "export.excel", {"export_type": "project_report", "num_projects": len(projects_data)}) + return send_file( output, - mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", as_attachment=True, - download_name=filename + download_name=filename, ) diff --git a/app/routes/saved_filters.py b/app/routes/saved_filters.py index 6be04c50..e7143aa7 100644 --- a/app/routes/saved_filters.py +++ b/app/routes/saved_filters.py @@ -18,283 +18,239 @@ logger = logging.getLogger(__name__) -saved_filters_bp = Blueprint('saved_filters', __name__) +saved_filters_bp = Blueprint("saved_filters", __name__) -@saved_filters_bp.route('/filters') +@saved_filters_bp.route("/filters") @login_required def list_filters(): """List all saved filters for the current user.""" - filters = SavedFilter.query.filter_by( - user_id=current_user.id - ).order_by(SavedFilter.created_at.desc()).all() - + filters = SavedFilter.query.filter_by(user_id=current_user.id).order_by(SavedFilter.created_at.desc()).all() + # Group by scope grouped_filters = {} for filter_obj in filters: if filter_obj.scope not in grouped_filters: grouped_filters[filter_obj.scope] = [] grouped_filters[filter_obj.scope].append(filter_obj) - - return render_template( - 'saved_filters/list.html', - filters=filters, - grouped_filters=grouped_filters - ) + + return render_template("saved_filters/list.html", filters=filters, grouped_filters=grouped_filters) -@saved_filters_bp.route('/api/filters', methods=['GET']) +@saved_filters_bp.route("/api/filters", methods=["GET"]) @login_required def get_filters_api(): """Get saved filters for the current user (API endpoint).""" - scope = request.args.get('scope') # Optional filter by scope - + scope = request.args.get("scope") # Optional filter by scope + query = SavedFilter.query.filter_by(user_id=current_user.id) - + if scope: query = query.filter_by(scope=scope) - + filters = query.order_by(SavedFilter.created_at.desc()).all() - - return jsonify({ - 'filters': [f.to_dict() for f in filters] - }) + return jsonify({"filters": [f.to_dict() for f in filters]}) -@saved_filters_bp.route('/api/filters', methods=['POST']) + +@saved_filters_bp.route("/api/filters", methods=["POST"]) @login_required def create_filter_api(): """Create a new saved filter (API endpoint).""" try: data = request.get_json() - - name = data.get('name', '').strip() - scope = data.get('scope', '').strip() - payload = data.get('payload', {}) - is_shared = data.get('is_shared', False) - + + name = data.get("name", "").strip() + scope = data.get("scope", "").strip() + payload = data.get("payload", {}) + is_shared = data.get("is_shared", False) + # Validation if not name: - return jsonify({'error': 'Filter name is required'}), 400 - + return jsonify({"error": "Filter name is required"}), 400 + if not scope: - return jsonify({'error': 'Filter scope is required'}), 400 - + return jsonify({"error": "Filter scope is required"}), 400 + # Check for duplicate - existing = SavedFilter.query.filter_by( - user_id=current_user.id, - name=name, - scope=scope - ).first() - + existing = SavedFilter.query.filter_by(user_id=current_user.id, name=name, scope=scope).first() + if existing: - return jsonify({'error': f'Filter "{name}" already exists for {scope}'}), 409 - + return jsonify({"error": f'Filter "{name}" already exists for {scope}'}), 409 + # Create filter saved_filter = SavedFilter( - user_id=current_user.id, - name=name, - scope=scope, - payload=payload, - is_shared=is_shared + user_id=current_user.id, name=name, scope=scope, payload=payload, is_shared=is_shared ) - + db.session.add(saved_filter) - if not safe_commit('create_saved_filter', {'name': name, 'scope': scope}): - return jsonify({'error': 'Could not save filter due to a database error'}), 500 - + if not safe_commit("create_saved_filter", {"name": name, "scope": scope}): + return jsonify({"error": "Could not save filter due to a database error"}), 500 + # Log activity Activity.log( user_id=current_user.id, - action='created', - entity_type='saved_filter', + action="created", + entity_type="saved_filter", entity_id=saved_filter.id, entity_name=saved_filter.name, description=f'Created saved filter "{saved_filter.name}" for {scope}', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event - log_event("saved_filter.created", - user_id=current_user.id, - filter_id=saved_filter.id, - filter_name=name, - scope=scope) - track_event(current_user.id, "saved_filter.created", { - "filter_id": saved_filter.id, - "filter_name": name, - "scope": scope, - "is_shared": is_shared - }) - - return jsonify({ - 'success': True, - 'filter': saved_filter.to_dict() - }), 201 - + log_event( + "saved_filter.created", user_id=current_user.id, filter_id=saved_filter.id, filter_name=name, scope=scope + ) + track_event( + current_user.id, + "saved_filter.created", + {"filter_id": saved_filter.id, "filter_name": name, "scope": scope, "is_shared": is_shared}, + ) + + return jsonify({"success": True, "filter": saved_filter.to_dict()}), 201 + except Exception as e: logger.error(f"Error creating saved filter: {e}") - return jsonify({'error': 'An error occurred while creating the filter'}), 500 + return jsonify({"error": "An error occurred while creating the filter"}), 500 -@saved_filters_bp.route('/api/filters/', methods=['GET']) +@saved_filters_bp.route("/api/filters/", methods=["GET"]) @login_required def get_filter_api(filter_id): """Get a specific saved filter (API endpoint).""" - saved_filter = SavedFilter.query.filter_by( - id=filter_id, - user_id=current_user.id - ).first_or_404() - + saved_filter = SavedFilter.query.filter_by(id=filter_id, user_id=current_user.id).first_or_404() + return jsonify(saved_filter.to_dict()) -@saved_filters_bp.route('/api/filters/', methods=['PUT']) +@saved_filters_bp.route("/api/filters/", methods=["PUT"]) @login_required def update_filter_api(filter_id): """Update a saved filter (API endpoint).""" try: - saved_filter = SavedFilter.query.filter_by( - id=filter_id, - user_id=current_user.id - ).first_or_404() - + saved_filter = SavedFilter.query.filter_by(id=filter_id, user_id=current_user.id).first_or_404() + data = request.get_json() - - name = data.get('name', '').strip() - payload = data.get('payload') - is_shared = data.get('is_shared') - + + name = data.get("name", "").strip() + payload = data.get("payload") + is_shared = data.get("is_shared") + if name: # Check for duplicate (excluding current filter) existing = SavedFilter.query.filter( SavedFilter.user_id == current_user.id, SavedFilter.name == name, SavedFilter.scope == saved_filter.scope, - SavedFilter.id != filter_id + SavedFilter.id != filter_id, ).first() - + if existing: - return jsonify({'error': f'Filter "{name}" already exists'}), 409 - + return jsonify({"error": f'Filter "{name}" already exists'}), 409 + saved_filter.name = name - + if payload is not None: saved_filter.payload = payload - + if is_shared is not None: saved_filter.is_shared = is_shared - - if not safe_commit('update_saved_filter', {'filter_id': filter_id}): - return jsonify({'error': 'Could not update filter due to a database error'}), 500 - + + if not safe_commit("update_saved_filter", {"filter_id": filter_id}): + return jsonify({"error": "Could not update filter due to a database error"}), 500 + # Log activity Activity.log( user_id=current_user.id, - action='updated', - entity_type='saved_filter', + action="updated", + entity_type="saved_filter", entity_id=saved_filter.id, entity_name=saved_filter.name, description=f'Updated saved filter "{saved_filter.name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event - log_event("saved_filter.updated", - user_id=current_user.id, - filter_id=saved_filter.id) - track_event(current_user.id, "saved_filter.updated", { - "filter_id": saved_filter.id, - "filter_name": saved_filter.name - }) - - return jsonify({ - 'success': True, - 'filter': saved_filter.to_dict() - }) - + log_event("saved_filter.updated", user_id=current_user.id, filter_id=saved_filter.id) + track_event( + current_user.id, "saved_filter.updated", {"filter_id": saved_filter.id, "filter_name": saved_filter.name} + ) + + return jsonify({"success": True, "filter": saved_filter.to_dict()}) + except Exception as e: logger.error(f"Error updating saved filter: {e}") - return jsonify({'error': 'An error occurred while updating the filter'}), 500 + return jsonify({"error": "An error occurred while updating the filter"}), 500 -@saved_filters_bp.route('/api/filters/', methods=['DELETE']) +@saved_filters_bp.route("/api/filters/", methods=["DELETE"]) @login_required def delete_filter_api(filter_id): """Delete a saved filter (API endpoint).""" try: - saved_filter = SavedFilter.query.filter_by( - id=filter_id, - user_id=current_user.id - ).first_or_404() - + saved_filter = SavedFilter.query.filter_by(id=filter_id, user_id=current_user.id).first_or_404() + filter_name = saved_filter.name filter_scope = saved_filter.scope - + db.session.delete(saved_filter) - if not safe_commit('delete_saved_filter', {'filter_id': filter_id}): - return jsonify({'error': 'Could not delete filter due to a database error'}), 500 - + if not safe_commit("delete_saved_filter", {"filter_id": filter_id}): + return jsonify({"error": "Could not delete filter due to a database error"}), 500 + # Log activity Activity.log( user_id=current_user.id, - action='deleted', - entity_type='saved_filter', + action="deleted", + entity_type="saved_filter", entity_id=filter_id, entity_name=filter_name, description=f'Deleted saved filter "{filter_name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event - log_event("saved_filter.deleted", - user_id=current_user.id, - filter_id=filter_id, - filter_name=filter_name) - track_event(current_user.id, "saved_filter.deleted", { - "filter_id": filter_id, - "filter_name": filter_name, - "scope": filter_scope - }) - - return jsonify({'success': True}), 200 - + log_event("saved_filter.deleted", user_id=current_user.id, filter_id=filter_id, filter_name=filter_name) + track_event( + current_user.id, + "saved_filter.deleted", + {"filter_id": filter_id, "filter_name": filter_name, "scope": filter_scope}, + ) + + return jsonify({"success": True}), 200 + except Exception as e: logger.error(f"Error deleting saved filter: {e}") - return jsonify({'error': 'An error occurred while deleting the filter'}), 500 + return jsonify({"error": "An error occurred while deleting the filter"}), 500 -@saved_filters_bp.route('/filters//delete', methods=['POST']) +@saved_filters_bp.route("/filters//delete", methods=["POST"]) @login_required def delete_filter(filter_id): """Delete a saved filter (web form).""" - saved_filter = SavedFilter.query.filter_by( - id=filter_id, - user_id=current_user.id - ).first_or_404() - + saved_filter = SavedFilter.query.filter_by(id=filter_id, user_id=current_user.id).first_or_404() + filter_name = saved_filter.name - + db.session.delete(saved_filter) - if not safe_commit('delete_saved_filter', {'filter_id': filter_id}): - flash(_('Could not delete filter due to a database error'), 'error') - return redirect(url_for('saved_filters.list_filters')) - + if not safe_commit("delete_saved_filter", {"filter_id": filter_id}): + flash(_("Could not delete filter due to a database error"), "error") + return redirect(url_for("saved_filters.list_filters")) + # Log activity Activity.log( user_id=current_user.id, - action='deleted', - entity_type='saved_filter', + action="deleted", + entity_type="saved_filter", entity_id=filter_id, entity_name=filter_name, description=f'Deleted saved filter "{filter_name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - - flash(f'Filter "{filter_name}" deleted successfully', 'success') - return redirect(url_for('saved_filters.list_filters')) + flash(f'Filter "{filter_name}" deleted successfully', "success") + return redirect(url_for("saved_filters.list_filters")) diff --git a/app/routes/scheduled_reports.py b/app/routes/scheduled_reports.py index 55f4762e..9b3d7e4f 100644 --- a/app/routes/scheduled_reports.py +++ b/app/routes/scheduled_reports.py @@ -8,66 +8,65 @@ from app.models import SavedReportView from app.services.scheduled_report_service import ScheduledReportService -scheduled_reports_bp = Blueprint('scheduled_reports', __name__) +scheduled_reports_bp = Blueprint("scheduled_reports", __name__) -@scheduled_reports_bp.route('/reports/scheduled') +@scheduled_reports_bp.route("/reports/scheduled") @login_required def list_scheduled(): """List scheduled reports""" service = ScheduledReportService() schedules = service.list_schedules(user_id=current_user.id) - - return render_template('reports/scheduled.html', schedules=schedules) + return render_template("reports/scheduled.html", schedules=schedules) -@scheduled_reports_bp.route('/reports/scheduled/create', methods=['GET', 'POST']) + +@scheduled_reports_bp.route("/reports/scheduled/create", methods=["GET", "POST"]) @login_required def create_scheduled(): """Create a scheduled report""" service = ScheduledReportService() saved_views = SavedReportView.query.filter_by(owner_id=current_user.id).all() - - if request.method == 'POST': - saved_view_id = request.form.get('saved_view_id', type=int) - recipients = request.form.get('recipients', '').strip() - cadence = request.form.get('cadence', '').strip() - cron = request.form.get('cron', '').strip() or None - timezone = request.form.get('timezone', '').strip() or None - + + if request.method == "POST": + saved_view_id = request.form.get("saved_view_id", type=int) + recipients = request.form.get("recipients", "").strip() + cadence = request.form.get("cadence", "").strip() + cron = request.form.get("cron", "").strip() or None + timezone = request.form.get("timezone", "").strip() or None + if not saved_view_id or not recipients or not cadence: - flash(_('Please fill in all required fields.'), 'error') - return render_template('reports/schedule_form.html', saved_views=saved_views) - + flash(_("Please fill in all required fields."), "error") + return render_template("reports/schedule_form.html", saved_views=saved_views) + result = service.create_schedule( saved_view_id=saved_view_id, recipients=recipients, cadence=cadence, created_by=current_user.id, cron=cron, - timezone=timezone + timezone=timezone, ) - - if result['success']: - flash(_('Scheduled report created successfully.'), 'success') - return redirect(url_for('scheduled_reports.list_scheduled')) + + if result["success"]: + flash(_("Scheduled report created successfully."), "success") + return redirect(url_for("scheduled_reports.list_scheduled")) else: - flash(result['message'], 'error') - - return render_template('reports/schedule_form.html', saved_views=saved_views) + flash(result["message"], "error") + + return render_template("reports/schedule_form.html", saved_views=saved_views) -@scheduled_reports_bp.route('/reports/scheduled//delete', methods=['POST']) +@scheduled_reports_bp.route("/reports/scheduled//delete", methods=["POST"]) @login_required def delete_scheduled(schedule_id): """Delete a scheduled report""" service = ScheduledReportService() result = service.delete_schedule(schedule_id, current_user.id) - - if result['success']: - flash(_('Scheduled report deleted successfully.'), 'success') + + if result["success"]: + flash(_("Scheduled report deleted successfully."), "success") else: - flash(result['message'], 'error') - - return redirect(url_for('scheduled_reports.list_scheduled')) + flash(result["message"], "error") + return redirect(url_for("scheduled_reports.list_scheduled")) diff --git a/app/routes/settings.py b/app/routes/settings.py index 38193934..04464f5e 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -9,33 +9,36 @@ from app import db, track_page_view from app.utils.db import safe_commit -settings_bp = Blueprint('settings', __name__) +settings_bp = Blueprint("settings", __name__) -@settings_bp.route('/settings') + +@settings_bp.route("/settings") @login_required def index(): """Main settings page""" - track_page_view('settings_index') - return render_template('settings/index.html') + track_page_view("settings_index") + return render_template("settings/index.html") + -@settings_bp.route('/settings/keyboard-shortcuts') +@settings_bp.route("/settings/keyboard-shortcuts") @login_required def keyboard_shortcuts(): """Keyboard shortcuts settings""" - track_page_view('settings_keyboard_shortcuts') - return render_template('settings/keyboard_shortcuts.html') + track_page_view("settings_keyboard_shortcuts") + return render_template("settings/keyboard_shortcuts.html") + -@settings_bp.route('/settings/profile') +@settings_bp.route("/settings/profile") @login_required def profile(): """User profile settings""" - track_page_view('settings_profile') - return redirect(url_for('profile.index')) + track_page_view("settings_profile") + return redirect(url_for("profile.index")) -@settings_bp.route('/settings/preferences') + +@settings_bp.route("/settings/preferences") @login_required def preferences(): """User preferences""" - track_page_view('settings_preferences') - return render_template('settings/preferences.html') - + track_page_view("settings_preferences") + return render_template("settings/preferences.html") diff --git a/app/routes/setup.py b/app/routes/setup.py index e6bc2c95..5b085cd1 100644 --- a/app/routes/setup.py +++ b/app/routes/setup.py @@ -10,35 +10,34 @@ from app.utils.installation import get_installation_config from app import log_event, track_event -setup_bp = Blueprint('setup', __name__) +setup_bp = Blueprint("setup", __name__) -@setup_bp.route('/setup', methods=['GET', 'POST']) +@setup_bp.route("/setup", methods=["GET", "POST"]) def initial_setup(): """Initial setup page for first-time users""" installation_config = get_installation_config() - + # If setup is already complete, redirect to dashboard if installation_config.is_setup_complete(): - return redirect(url_for('main.dashboard')) - - if request.method == 'POST': + return redirect(url_for("main.dashboard")) + + if request.method == "POST": # Get telemetry preference - telemetry_enabled = request.form.get('telemetry_enabled') == 'on' - + telemetry_enabled = request.form.get("telemetry_enabled") == "on" + # Save preference installation_config.mark_setup_complete(telemetry_enabled=telemetry_enabled) - + # Log the setup completion log_event("setup.completed", telemetry_enabled=telemetry_enabled) - + # Show success message if telemetry_enabled: - flash(_('Setup complete! Thank you for helping us improve TimeTracker.'), 'success') + flash(_("Setup complete! Thank you for helping us improve TimeTracker."), "success") else: - flash(_('Setup complete! Telemetry is disabled.'), 'success') - - return redirect(url_for('main.dashboard')) - - return render_template('setup/initial_setup.html') + flash(_("Setup complete! Telemetry is disabled."), "success") + + return redirect(url_for("main.dashboard")) + return render_template("setup/initial_setup.html") diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 58dd16a5..87e35d81 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -11,23 +11,24 @@ import csv import io -tasks_bp = Blueprint('tasks', __name__) +tasks_bp = Blueprint("tasks", __name__) -@tasks_bp.route('/tasks') + +@tasks_bp.route("/tasks") @login_required def list_tasks(): """List all tasks with filtering options - REFACTORED to use service layer with eager loading""" from app.services import TaskService - - page = request.args.get('page', 1, type=int) - status = request.args.get('status', '') - priority = request.args.get('priority', '') - project_id = request.args.get('project_id', type=int) - assigned_to = request.args.get('assigned_to', type=int) - search = request.args.get('search', '').strip() - overdue_param = request.args.get('overdue', '').strip().lower() - overdue = overdue_param in ['1', 'true', 'on', 'yes'] - + + page = request.args.get("page", 1, type=int) + status = request.args.get("status", "") + priority = request.args.get("priority", "") + project_id = request.args.get("project_id", type=int) + assigned_to = request.args.get("assigned_to", type=int) + search = request.args.get("search", "").strip() + overdue_param = request.args.get("overdue", "").strip().lower() + overdue = overdue_param in ["1", "true", "on", "yes"] + # Use service layer to get tasks (prevents N+1 queries) task_service = TaskService() result = task_service.list_tasks( @@ -40,21 +41,21 @@ def list_tasks(): user_id=current_user.id, is_admin=current_user.is_admin, page=page, - per_page=20 + per_page=20, ) - + # Get filter options (these could also be cached) - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() users = User.query.order_by(User.username).all() # Force fresh kanban columns from database (no cache) db.session.expire_all() kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else [] - + # Prevent browser caching of kanban board response = render_template( - 'tasks/list.html', - tasks=result['tasks'], - pagination=result['pagination'], + "tasks/list.html", + tasks=result["tasks"], + pagination=result["pagination"], projects=projects, users=users, kanban_columns=kanban_columns, @@ -63,58 +64,60 @@ def list_tasks(): project_id=project_id, assigned_to=assigned_to, search=search, - overdue=overdue + overdue=overdue, ) resp = make_response(response) - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' - resp.headers['Pragma'] = 'no-cache' - resp.headers['Expires'] = '0' + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" return resp -@tasks_bp.route('/tasks/create', methods=['GET', 'POST']) + +@tasks_bp.route("/tasks/create", methods=["GET", "POST"]) @login_required def create_task(): """Create a new task""" - if request.method == 'POST': - project_id = request.form.get('project_id', type=int) - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - priority = request.form.get('priority', 'medium') - estimated_hours = request.form.get('estimated_hours', '').strip() - due_date_str = request.form.get('due_date', '').strip() - assigned_to = request.form.get('assigned_to', type=int) - + if request.method == "POST": + project_id = request.form.get("project_id", type=int) + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + priority = request.form.get("priority", "medium") + estimated_hours = request.form.get("estimated_hours", "").strip() + due_date_str = request.form.get("due_date", "").strip() + assigned_to = request.form.get("assigned_to", type=int) + # Validate required fields if not project_id or not name: - flash(_('Project and task name are required'), 'error') - return render_template('tasks/create.html') - + flash(_("Project and task name are required"), "error") + return render_template("tasks/create.html") + # Validate project exists project = Project.query.get(project_id) if not project: - flash(_('Selected project does not exist'), 'error') - return render_template('tasks/create.html') - + flash(_("Selected project does not exist"), "error") + return render_template("tasks/create.html") + # Parse estimated hours try: estimated_hours = float(estimated_hours) if estimated_hours else None except ValueError: - flash(_('Invalid estimated hours format'), 'error') - return render_template('tasks/create.html') - + flash(_("Invalid estimated hours format"), "error") + return render_template("tasks/create.html") + # Parse due date due_date = None if due_date_str: try: - due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date() + due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid due date format'), 'error') - return render_template('tasks/create.html') - + flash(_("Invalid due date format"), "error") + return render_template("tasks/create.html") + # Use service layer to create task from app.services import TaskService + task_service = TaskService() - + result = task_service.create_task( name=name, project_id=project_id, @@ -123,144 +126,152 @@ def create_task(): priority=priority, due_date=due_date, estimated_hours=estimated_hours, - created_by=current_user.id + created_by=current_user.id, ) - - if not result['success']: - flash(_(result['message']), 'error') - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + + if not result["success"]: + flash(_(result["message"]), "error") + projects = Project.query.filter_by(status="active").order_by(Project.name).all() users = User.query.order_by(User.username).all() - return render_template('tasks/create.html', projects=projects, users=users) - - task = result['task'] - + return render_template("tasks/create.html", projects=projects, users=users) + + task = result["task"] + # Log task creation - app_module.log_event("task.created", - user_id=current_user.id, - task_id=task.id, - project_id=project_id, - priority=priority) - app_module.track_event(current_user.id, "task.created", { - "task_id": task.id, - "project_id": project_id, - "priority": priority - }) - + app_module.log_event( + "task.created", user_id=current_user.id, task_id=task.id, project_id=project_id, priority=priority + ) + app_module.track_event( + current_user.id, "task.created", {"task_id": task.id, "project_id": project_id, "priority": priority} + ) + # Log activity Activity.log( user_id=current_user.id, - action='created', - entity_type='task', + action="created", + entity_type="task", entity_id=task.id, entity_name=task.name, description=f'Created task "{task.name}" in project "{project.name}"', - extra_data={'project_id': project_id, 'priority': priority}, + extra_data={"project_id": project_id, "priority": priority}, ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - - flash(f'Task "{name}" created successfully', 'success') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + + flash(f'Task "{name}" created successfully', "success") + return redirect(url_for("tasks.view_task", task_id=task.id)) + # Get available projects and users for form - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() users = User.query.order_by(User.username).all() - - return render_template('tasks/create.html', projects=projects, users=users) -@tasks_bp.route('/tasks/') + return render_template("tasks/create.html", projects=projects, users=users) + + +@tasks_bp.route("/tasks/") @login_required def view_task(task_id): """View task details - REFACTORED to use service layer with eager loading""" from app.services import TaskService - + task_service = TaskService() - + # Get task with all relations using eager loading (prevents N+1 queries) task = task_service.get_task_with_details( - task_id=task_id, - include_time_entries=True, - include_comments=True, - include_activities=True + task_id=task_id, include_time_entries=True, include_comments=True, include_activities=True ) - + if not task: - flash(_('Task not found'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("Task not found"), "error") + return redirect(url_for("tasks.list_tasks")) + # Check if user has access to this task if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id: - flash(_('You do not have access to this task'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("You do not have access to this task"), "error") + return redirect(url_for("tasks.list_tasks")) + # Get time entries (time_entries is a dynamic relationship, so query it) # Eagerly load user relationship to prevent N+1 queries from sqlalchemy.orm import joinedload - time_entries = task.time_entries.options(joinedload(TimeEntry.user)).order_by(TimeEntry.start_time.desc(), TimeEntry.id.desc()).all() - + + time_entries = ( + task.time_entries.options(joinedload(TimeEntry.user)) + .order_by(TimeEntry.start_time.desc(), TimeEntry.id.desc()) + .all() + ) + # Recent activity entries (activities is a dynamic relationship, so query it) # Eagerly load user relationship to prevent N+1 queries - activities = task.activities.options(joinedload(TaskActivity.user)).order_by(TaskActivity.created_at.desc(), TaskActivity.id.desc()).limit(20).all() - + activities = ( + task.activities.options(joinedload(TaskActivity.user)) + .order_by(TaskActivity.created_at.desc(), TaskActivity.id.desc()) + .limit(20) + .all() + ) + # Get comments for this task from app.models import Comment + comments = Comment.get_task_comments(task_id, include_replies=True) - - return render_template('tasks/view.html', task=task, time_entries=time_entries, activities=activities, comments=comments) -@tasks_bp.route('/tasks//edit', methods=['GET', 'POST']) + return render_template( + "tasks/view.html", task=task, time_entries=time_entries, activities=activities, comments=comments + ) + + +@tasks_bp.route("/tasks//edit", methods=["GET", "POST"]) @login_required def edit_task(task_id): """Edit task details""" task = Task.query.get_or_404(task_id) - + # Check if user can edit this task if not current_user.is_admin and task.created_by != current_user.id: - flash(_('You can only edit tasks you created'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - - if request.method == 'POST': + flash(_("You can only edit tasks you created"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + + if request.method == "POST": # Preload context for potential validation errors - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() users = User.query.order_by(User.username).all() - project_id = request.form.get('project_id', type=int) - name = request.form.get('name', '').strip() - description = request.form.get('description', '').strip() - priority = request.form.get('priority', 'medium') - estimated_hours = request.form.get('estimated_hours', '').strip() - due_date_str = request.form.get('due_date', '').strip() - assigned_to = request.form.get('assigned_to', type=int) - + project_id = request.form.get("project_id", type=int) + name = request.form.get("name", "").strip() + description = request.form.get("description", "").strip() + priority = request.form.get("priority", "medium") + estimated_hours = request.form.get("estimated_hours", "").strip() + due_date_str = request.form.get("due_date", "").strip() + assigned_to = request.form.get("assigned_to", type=int) + # Validate required fields if not name: - flash(_('Task name is required'), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) + flash(_("Task name is required"), "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) # Validate project selection if not project_id: - flash(_('Project is required'), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) - new_project = Project.query.filter_by(id=project_id, status='active').first() + flash(_("Project is required"), "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) + new_project = Project.query.filter_by(id=project_id, status="active").first() if not new_project: - flash(_('Selected project does not exist or is inactive'), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) - + flash(_("Selected project does not exist or is inactive"), "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) + # Parse estimated hours try: estimated_hours = float(estimated_hours) if estimated_hours else None except ValueError: - flash(_('Invalid estimated hours format'), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) - + flash(_("Invalid estimated hours format"), "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) + # Parse due date due_date = None if due_date_str: try: - due_date = datetime.strptime(due_date_str, '%Y-%m-%d').date() + due_date = datetime.strptime(due_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid due date format'), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) - + flash(_("Invalid due date format"), "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) + # Update task # Handle project change first so any early returns (status flows) still persist it if project_id != task.project_id: @@ -270,7 +281,14 @@ def edit_task(task_id): try: for entry in task.time_entries.all(): entry.project_id = project_id - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='project_change', details=f"Project changed from {old_project_id} to {project_id}")) + db.session.add( + TaskActivity( + task_id=task.id, + user_id=current_user.id, + event="project_change", + details=f"Project changed from {old_project_id} to {project_id}", + ) + ) except Exception: # If anything goes wrong here, fall back to just changing the task pass @@ -282,698 +300,777 @@ def edit_task(task_id): task.due_date = due_date task.assigned_to = assigned_to # Handle status update (including reopening from done) - selected_status = request.form.get('status', '').strip() + selected_status = request.form.get("status", "").strip() valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id) if selected_status and selected_status in valid_statuses and selected_status != task.status: try: previous_status = task.status - if selected_status == 'in_progress': + if selected_status == "in_progress": # If reopening from done, preserve started_at - if task.status == 'done': + if task.status == "done": task.completed_at = None - task.status = 'in_progress' + task.status = "in_progress" if not task.started_at: task.started_at = now_in_app_timezone() task.updated_at = now_in_app_timezone() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) - if not safe_commit('edit_task_reopen_in_progress', {'task_id': task.id}): - flash(_('Could not update status due to a database error. Please check server logs.'), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) + db.session.add( + TaskActivity( + task_id=task.id, + user_id=current_user.id, + event="reopen", + details="Task reopened to In Progress", + ) + ) + if not safe_commit("edit_task_reopen_in_progress", {"task_id": task.id}): + flash( + _("Could not update status due to a database error. Please check server logs."), "error" + ) + return render_template("tasks/edit.html", task=task, projects=projects, users=users) else: task.start_task() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) - safe_commit('log_task_start_from_edit', {'task_id': task.id}) - elif selected_status == 'done': + db.session.add( + TaskActivity( + task_id=task.id, + user_id=current_user.id, + event="start", + details=f"Task moved from {previous_status} to In Progress", + ) + ) + safe_commit("log_task_start_from_edit", {"task_id": task.id}) + elif selected_status == "done": task.complete_task() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed')) - safe_commit('log_task_complete_from_edit', {'task_id': task.id}) - elif selected_status == 'cancelled': + db.session.add( + TaskActivity( + task_id=task.id, user_id=current_user.id, event="complete", details="Task completed" + ) + ) + safe_commit("log_task_complete_from_edit", {"task_id": task.id}) + elif selected_status == "cancelled": task.cancel_task() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled')) - safe_commit('log_task_cancel_from_edit', {'task_id': task.id}) + db.session.add( + TaskActivity(task_id=task.id, user_id=current_user.id, event="cancel", details="Task cancelled") + ) + safe_commit("log_task_cancel_from_edit", {"task_id": task.id}) else: # Reopen or move to non-special states # Clear completed_at if reopening from done - if task.status == 'done' and selected_status in ['todo', 'review']: + if task.status == "done" and selected_status in ["todo", "review"]: task.completed_at = None task.status = selected_status task.updated_at = now_in_app_timezone() - event_name = 'reopen' if previous_status == 'done' and selected_status in ['todo', 'review'] else ('pause' if selected_status == 'todo' else ('review' if selected_status == 'review' else 'status_change')) - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {selected_status}")) - if not safe_commit('edit_task_status_change', {'task_id': task.id, 'status': selected_status}): - flash('Could not update status due to a database error. Please check server logs.', 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) + event_name = ( + "reopen" + if previous_status == "done" and selected_status in ["todo", "review"] + else ( + "pause" + if selected_status == "todo" + else ("review" if selected_status == "review" else "status_change") + ) + ) + db.session.add( + TaskActivity( + task_id=task.id, + user_id=current_user.id, + event=event_name, + details=f"Task moved from {previous_status} to {selected_status}", + ) + ) + if not safe_commit("edit_task_status_change", {"task_id": task.id, "status": selected_status}): + flash("Could not update status due to a database error. Please check server logs.", "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) except ValueError as e: - flash(str(e), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) + flash(str(e), "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) # Always update the updated_at timestamp to local time after edits task.updated_at = now_in_app_timezone() - - if not safe_commit('edit_task', {'task_id': task.id}): - flash(_('Could not update task due to a database error. Please check server logs.'), 'error') - return render_template('tasks/edit.html', task=task, projects=projects, users=users) - + + if not safe_commit("edit_task", {"task_id": task.id}): + flash(_("Could not update task due to a database error. Please check server logs."), "error") + return render_template("tasks/edit.html", task=task, projects=projects, users=users) + # Log task update - app_module.log_event("task.updated", - user_id=current_user.id, - task_id=task.id, - project_id=task.project_id) - app_module.track_event(current_user.id, "task.updated", { - "task_id": task.id, - "project_id": task.project_id - }) - + app_module.log_event("task.updated", user_id=current_user.id, task_id=task.id, project_id=task.project_id) + app_module.track_event(current_user.id, "task.updated", {"task_id": task.id, "project_id": task.project_id}) + # Log activity Activity.log( user_id=current_user.id, - action='updated', - entity_type='task', + action="updated", + entity_type="task", entity_id=task.id, entity_name=task.name, description=f'Updated task "{task.name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - - flash(f'Task "{name}" updated successfully', 'success') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + + flash(f'Task "{name}" updated successfully', "success") + return redirect(url_for("tasks.view_task", task_id=task.id)) + # Get available projects and users for form - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() users = User.query.order_by(User.username).all() - - return render_template('tasks/edit.html', task=task, projects=projects, users=users) -@tasks_bp.route('/tasks//status', methods=['POST']) + return render_template("tasks/edit.html", task=task, projects=projects, users=users) + + +@tasks_bp.route("/tasks//status", methods=["POST"]) @login_required def update_task_status(task_id): """Update task status""" task = Task.query.get_or_404(task_id) - new_status = request.form.get('status', '').strip() - + new_status = request.form.get("status", "").strip() + # Check if user can update this task if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id: - flash(_('You do not have permission to update this task'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + flash(_("You do not have permission to update this task"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + # Validate status against configured kanban columns for this task's project valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id) if new_status not in valid_statuses: - flash(_('Invalid status'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + flash(_("Invalid status"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + # Update status try: - if new_status == 'in_progress': + if new_status == "in_progress": # If reopening from done, bypass start_task restriction - if task.status == 'done': + if task.status == "done": task.completed_at = None - task.status = 'in_progress' + task.status = "in_progress" # Preserve existing started_at if present, otherwise set now if not task.started_at: task.started_at = now_in_app_timezone() task.updated_at = now_in_app_timezone() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='reopen', details='Task reopened to In Progress')) - if not safe_commit('update_task_status_reopen_in_progress', {'task_id': task.id, 'status': new_status}): - flash('Could not update status due to a database error. Please check server logs.', 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) + db.session.add( + TaskActivity( + task_id=task.id, user_id=current_user.id, event="reopen", details="Task reopened to In Progress" + ) + ) + if not safe_commit("update_task_status_reopen_in_progress", {"task_id": task.id, "status": new_status}): + flash("Could not update status due to a database error. Please check server logs.", "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) else: previous_status = task.status task.start_task() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='start', details=f"Task moved from {previous_status} to In Progress")) - safe_commit('log_task_start', {'task_id': task.id}) - elif new_status == 'done': + db.session.add( + TaskActivity( + task_id=task.id, + user_id=current_user.id, + event="start", + details=f"Task moved from {previous_status} to In Progress", + ) + ) + safe_commit("log_task_start", {"task_id": task.id}) + elif new_status == "done": task.complete_task() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='complete', details='Task completed')) - safe_commit('log_task_complete', {'task_id': task.id}) - elif new_status == 'cancelled': + db.session.add( + TaskActivity(task_id=task.id, user_id=current_user.id, event="complete", details="Task completed") + ) + safe_commit("log_task_complete", {"task_id": task.id}) + elif new_status == "cancelled": task.cancel_task() - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event='cancel', details='Task cancelled')) - safe_commit('log_task_cancel', {'task_id': task.id}) + db.session.add( + TaskActivity(task_id=task.id, user_id=current_user.id, event="cancel", details="Task cancelled") + ) + safe_commit("log_task_cancel", {"task_id": task.id}) else: # For other transitions, handle reopening from done and local timestamps - if task.status == 'done' and new_status in ['todo', 'review']: + if task.status == "done" and new_status in ["todo", "review"]: task.completed_at = None previous_status = task.status task.status = new_status task.updated_at = now_in_app_timezone() # Log pause or review or generic change - if previous_status == 'done' and new_status in ['todo', 'review']: - event_name = 'reopen' + if previous_status == "done" and new_status in ["todo", "review"]: + event_name = "reopen" else: event_map = { - 'todo': 'pause', - 'review': 'review', + "todo": "pause", + "review": "review", } - event_name = event_map.get(new_status, 'status_change') - db.session.add(TaskActivity(task_id=task.id, user_id=current_user.id, event=event_name, details=f"Task moved from {previous_status} to {new_status}")) - if not safe_commit('update_task_status', {'task_id': task.id, 'status': new_status}): - flash('Could not update status due to a database error. Please check server logs.', 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + event_name = event_map.get(new_status, "status_change") + db.session.add( + TaskActivity( + task_id=task.id, + user_id=current_user.id, + event=event_name, + details=f"Task moved from {previous_status} to {new_status}", + ) + ) + if not safe_commit("update_task_status", {"task_id": task.id, "status": new_status}): + flash("Could not update status due to a database error. Please check server logs.", "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + # Log task status change - app_module.log_event("task.status_changed", - user_id=current_user.id, - task_id=task.id, - old_status=previous_status, - new_status=new_status) - app_module.track_event(current_user.id, "task.status_changed", { - "task_id": task.id, - "old_status": previous_status, - "new_status": new_status - }) - - flash(f'Task status updated to {task.status_display}', 'success') + app_module.log_event( + "task.status_changed", + user_id=current_user.id, + task_id=task.id, + old_status=previous_status, + new_status=new_status, + ) + app_module.track_event( + current_user.id, + "task.status_changed", + {"task_id": task.id, "old_status": previous_status, "new_status": new_status}, + ) + + flash(f"Task status updated to {task.status_display}", "success") except ValueError as e: - flash(str(e), 'error') - - return redirect(url_for('tasks.view_task', task_id=task.id)) + flash(str(e), "error") -@tasks_bp.route('/tasks//priority', methods=['POST']) + return redirect(url_for("tasks.view_task", task_id=task.id)) + + +@tasks_bp.route("/tasks//priority", methods=["POST"]) @login_required def update_task_priority(task_id): """Update task priority""" task = Task.query.get_or_404(task_id) - new_priority = request.form.get('priority', '').strip() - + new_priority = request.form.get("priority", "").strip() + # Check if user can update this task if not current_user.is_admin and task.created_by != current_user.id: - flash(_('You can only update tasks you created'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + flash(_("You can only update tasks you created"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + try: task.update_priority(new_priority) - flash(f'Task priority updated to {task.priority_display}', 'success') + flash(f"Task priority updated to {task.priority_display}", "success") except ValueError as e: - flash(str(e), 'error') - - return redirect(url_for('tasks.view_task', task_id=task.id)) + flash(str(e), "error") + + return redirect(url_for("tasks.view_task", task_id=task.id)) + -@tasks_bp.route('/tasks//assign', methods=['POST']) +@tasks_bp.route("/tasks//assign", methods=["POST"]) @login_required def assign_task(task_id): """Assign task to a user""" task = Task.query.get_or_404(task_id) - user_id = request.form.get('user_id', type=int) - + user_id = request.form.get("user_id", type=int) + # Check if user can assign this task if not current_user.is_admin and task.created_by != current_user.id: - flash(_('You can only assign tasks you created'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + flash(_("You can only assign tasks you created"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + if user_id: user = User.query.get(user_id) if not user: - flash(_('Selected user does not exist'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + flash(_("Selected user does not exist"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + task.reassign(user_id) if user_id: - flash(f'Task assigned to {user.username}', 'success') + flash(f"Task assigned to {user.username}", "success") else: - flash(_('Task unassigned'), 'success') - - return redirect(url_for('tasks.view_task', task_id=task.id)) + flash(_("Task unassigned"), "success") -@tasks_bp.route('/tasks//delete', methods=['POST']) + return redirect(url_for("tasks.view_task", task_id=task.id)) + + +@tasks_bp.route("/tasks//delete", methods=["POST"]) @login_required def delete_task(task_id): """Delete a task""" task = Task.query.get_or_404(task_id) - + # Check if user can delete this task if not current_user.is_admin and task.created_by != current_user.id: - flash(_('You can only delete tasks you created'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + flash(_("You can only delete tasks you created"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + # Check if task has time entries if task.time_entries.count() > 0: - flash(_('Cannot delete task with existing time entries'), 'error') - return redirect(url_for('tasks.view_task', task_id=task.id)) - + flash(_("Cannot delete task with existing time entries"), "error") + return redirect(url_for("tasks.view_task", task_id=task.id)) + task_name = task.name task_id_for_log = task.id project_id_for_log = task.project_id - + # Log activity before deletion Activity.log( user_id=current_user.id, - action='deleted', - entity_type='task', + action="deleted", + entity_type="task", entity_id=task_id_for_log, entity_name=task_name, description=f'Deleted task "{task_name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + db.session.delete(task) - if not safe_commit('delete_task', {'task_id': task_id_for_log}): - flash(_('Could not delete task due to a database error. Please check server logs.'), 'error') - return redirect(url_for('tasks.view_task', task_id=task_id_for_log)) - + if not safe_commit("delete_task", {"task_id": task_id_for_log}): + flash(_("Could not delete task due to a database error. Please check server logs."), "error") + return redirect(url_for("tasks.view_task", task_id=task_id_for_log)) + # Log task deletion - app_module.log_event("task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log) - app_module.track_event(current_user.id, "task.deleted", {"task_id": task_id_for_log, "project_id": project_id_for_log}) - - flash(f'Task "{task_name}" deleted successfully', 'success') - return redirect(url_for('tasks.list_tasks')) + app_module.log_event( + "task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log + ) + app_module.track_event( + current_user.id, "task.deleted", {"task_id": task_id_for_log, "project_id": project_id_for_log} + ) -@tasks_bp.route('/tasks/bulk-delete', methods=['POST']) + flash(f'Task "{task_name}" deleted successfully', "success") + return redirect(url_for("tasks.list_tasks")) + + +@tasks_bp.route("/tasks/bulk-delete", methods=["POST"]) @login_required def bulk_delete_tasks(): """Delete multiple tasks at once""" - task_ids = request.form.getlist('task_ids[]') - + task_ids = request.form.getlist("task_ids[]") + if not task_ids: - flash(_('No tasks selected for deletion'), 'warning') - return redirect(url_for('tasks.list_tasks')) - + flash(_("No tasks selected for deletion"), "warning") + return redirect(url_for("tasks.list_tasks")) + deleted_count = 0 skipped_count = 0 errors = [] - + for task_id_str in task_ids: try: task_id = int(task_id_str) task = Task.query.get(task_id) - + if not task: continue - + # Check permissions if not current_user.is_admin and task.created_by != current_user.id: skipped_count += 1 errors.append(f"'{task.name}': No permission") continue - + # Check for time entries if task.time_entries.count() > 0: skipped_count += 1 errors.append(f"'{task.name}': Has time entries") continue - + # Delete the task task_id_for_log = task.id project_id_for_log = task.project_id task_name = task.name - + db.session.delete(task) deleted_count += 1 - + # Log the deletion - app_module.log_event("task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log) - app_module.track_event(current_user.id, "task.deleted", {"task_id": task_id_for_log, "project_id": project_id_for_log}) - + app_module.log_event( + "task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log + ) + app_module.track_event( + current_user.id, "task.deleted", {"task_id": task_id_for_log, "project_id": project_id_for_log} + ) + except Exception as e: skipped_count += 1 errors.append(f"ID {task_id_str}: {str(e)}") - + # Commit all deletions if deleted_count > 0: - if not safe_commit('bulk_delete_tasks', {'count': deleted_count}): - flash(_('Could not delete tasks due to a database error. Please check server logs.'), 'error') - return redirect(url_for('tasks.list_tasks')) - + if not safe_commit("bulk_delete_tasks", {"count": deleted_count}): + flash(_("Could not delete tasks due to a database error. Please check server logs."), "error") + return redirect(url_for("tasks.list_tasks")) + # Show appropriate messages if deleted_count > 0: - flash(f'Successfully deleted {deleted_count} task{"s" if deleted_count != 1 else ""}', 'success') - + flash(f'Successfully deleted {deleted_count} task{"s" if deleted_count != 1 else ""}', "success") + if skipped_count > 0: - flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""}: {"; ".join(errors[:3])}', 'warning') - - return redirect(url_for('tasks.list_tasks')) + flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""}: {"; ".join(errors[:3])}', "warning") + + return redirect(url_for("tasks.list_tasks")) -@tasks_bp.route('/tasks/bulk-status', methods=['POST']) +@tasks_bp.route("/tasks/bulk-status", methods=["POST"]) @login_required def bulk_update_status(): """Update status for multiple tasks at once""" - task_ids = request.form.getlist('task_ids[]') - new_status = request.form.get('status', '').strip() - + task_ids = request.form.getlist("task_ids[]") + new_status = request.form.get("status", "").strip() + if not task_ids: - flash(_('No tasks selected'), 'warning') - return redirect(url_for('tasks.list_tasks')) - + flash(_("No tasks selected"), "warning") + return redirect(url_for("tasks.list_tasks")) + if not new_status: - flash(_('Invalid status value'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("Invalid status value"), "error") + return redirect(url_for("tasks.list_tasks")) + updated_count = 0 skipped_count = 0 - + for task_id_str in task_ids: try: task_id = int(task_id_str) task = Task.query.get(task_id) - + # Validate status against configured kanban columns for this task's project - valid_statuses = set(KanbanColumn.get_valid_status_keys(project_id=task.project_id) if KanbanColumn else ['todo','in_progress','review','done','cancelled']) + valid_statuses = set( + KanbanColumn.get_valid_status_keys(project_id=task.project_id) + if KanbanColumn + else ["todo", "in_progress", "review", "done", "cancelled"] + ) if new_status not in valid_statuses: skipped_count += 1 continue - + if not task: continue - + # Check permissions if not current_user.is_admin and task.created_by != current_user.id: skipped_count += 1 continue - + # Handle reopening from done if needed - if task.status == 'done' and new_status in ['todo', 'review', 'in_progress']: + if task.status == "done" and new_status in ["todo", "review", "in_progress"]: task.completed_at = None task.status = new_status task.updated_at = now_in_app_timezone() updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: - if not safe_commit('bulk_update_task_status', {'count': updated_count, 'status': new_status}): - flash(_('Could not update tasks due to a database error'), 'error') - return redirect(url_for('tasks.list_tasks')) - - flash(f'Successfully updated {updated_count} task{"s" if updated_count != 1 else ""} to {new_status}', 'success') - + if not safe_commit("bulk_update_task_status", {"count": updated_count, "status": new_status}): + flash(_("Could not update tasks due to a database error"), "error") + return redirect(url_for("tasks.list_tasks")) + + flash( + f'Successfully updated {updated_count} task{"s" if updated_count != 1 else ""} to {new_status}', "success" + ) + if skipped_count > 0: - flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', 'warning') - - return redirect(url_for('tasks.list_tasks')) + flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning") + + return redirect(url_for("tasks.list_tasks")) -@tasks_bp.route('/tasks/bulk-priority', methods=['POST']) +@tasks_bp.route("/tasks/bulk-priority", methods=["POST"]) @login_required def bulk_update_priority(): """Update priority for multiple tasks at once""" - task_ids = request.form.getlist('task_ids[]') - new_priority = request.form.get('priority', '').strip() - + task_ids = request.form.getlist("task_ids[]") + new_priority = request.form.get("priority", "").strip() + if not task_ids: - flash(_('No tasks selected'), 'warning') - return redirect(url_for('tasks.list_tasks')) - - if not new_priority or new_priority not in ['low', 'medium', 'high', 'urgent']: - flash(_('Invalid priority value'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("No tasks selected"), "warning") + return redirect(url_for("tasks.list_tasks")) + + if not new_priority or new_priority not in ["low", "medium", "high", "urgent"]: + flash(_("Invalid priority value"), "error") + return redirect(url_for("tasks.list_tasks")) + updated_count = 0 skipped_count = 0 - + for task_id_str in task_ids: try: task_id = int(task_id_str) task = Task.query.get(task_id) - + if not task: continue - + # Check permissions if not current_user.is_admin and task.created_by != current_user.id: skipped_count += 1 continue - + task.priority = new_priority updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: - if not safe_commit('bulk_update_task_priority', {'count': updated_count, 'priority': new_priority}): - flash(_('Could not update tasks due to a database error'), 'error') - return redirect(url_for('tasks.list_tasks')) - - flash(f'Successfully updated {updated_count} task{"s" if updated_count != 1 else ""} to {new_priority} priority', 'success') - + if not safe_commit("bulk_update_task_priority", {"count": updated_count, "priority": new_priority}): + flash(_("Could not update tasks due to a database error"), "error") + return redirect(url_for("tasks.list_tasks")) + + flash( + f'Successfully updated {updated_count} task{"s" if updated_count != 1 else ""} to {new_priority} priority', + "success", + ) + if skipped_count > 0: - flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', 'warning') - - return redirect(url_for('tasks.list_tasks')) + flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning") + return redirect(url_for("tasks.list_tasks")) -@tasks_bp.route('/tasks/bulk-assign', methods=['POST']) + +@tasks_bp.route("/tasks/bulk-assign", methods=["POST"]) @login_required def bulk_assign_tasks(): """Assign multiple tasks to a user""" - task_ids = request.form.getlist('task_ids[]') - assigned_to = request.form.get('assigned_to', type=int) - + task_ids = request.form.getlist("task_ids[]") + assigned_to = request.form.get("assigned_to", type=int) + if not task_ids: - flash(_('No tasks selected'), 'warning') - return redirect(url_for('tasks.list_tasks')) - + flash(_("No tasks selected"), "warning") + return redirect(url_for("tasks.list_tasks")) + if not assigned_to: - flash(_('No user selected for assignment'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("No user selected for assignment"), "error") + return redirect(url_for("tasks.list_tasks")) + # Verify user exists user = User.query.get(assigned_to) if not user: - flash(_('Invalid user selected'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("Invalid user selected"), "error") + return redirect(url_for("tasks.list_tasks")) + updated_count = 0 skipped_count = 0 - + for task_id_str in task_ids: try: task_id = int(task_id_str) task = Task.query.get(task_id) - + if not task: continue - + # Check permissions if not current_user.is_admin and task.created_by != current_user.id: skipped_count += 1 continue - + task.assigned_to = assigned_to updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: - if not safe_commit('bulk_assign_tasks', {'count': updated_count, 'assigned_to': assigned_to}): - flash(_('Could not assign tasks due to a database error'), 'error') - return redirect(url_for('tasks.list_tasks')) - - flash(f'Successfully assigned {updated_count} task{"s" if updated_count != 1 else ""} to {user.display_name}', 'success') - + if not safe_commit("bulk_assign_tasks", {"count": updated_count, "assigned_to": assigned_to}): + flash(_("Could not assign tasks due to a database error"), "error") + return redirect(url_for("tasks.list_tasks")) + + flash( + f'Successfully assigned {updated_count} task{"s" if updated_count != 1 else ""} to {user.display_name}', + "success", + ) + if skipped_count > 0: - flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', 'warning') - - return redirect(url_for('tasks.list_tasks')) + flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning") + return redirect(url_for("tasks.list_tasks")) -@tasks_bp.route('/tasks/bulk-move-project', methods=['POST']) + +@tasks_bp.route("/tasks/bulk-move-project", methods=["POST"]) @login_required def bulk_move_project(): """Move multiple tasks to a different project""" - task_ids = request.form.getlist('task_ids[]') - new_project_id = request.form.get('project_id', type=int) - + task_ids = request.form.getlist("task_ids[]") + new_project_id = request.form.get("project_id", type=int) + if not task_ids: - flash(_('No tasks selected'), 'warning') - return redirect(url_for('tasks.list_tasks')) - + flash(_("No tasks selected"), "warning") + return redirect(url_for("tasks.list_tasks")) + if not new_project_id: - flash(_('No project selected'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("No project selected"), "error") + return redirect(url_for("tasks.list_tasks")) + # Verify project exists and is active - new_project = Project.query.filter_by(id=new_project_id, status='active').first() + new_project = Project.query.filter_by(id=new_project_id, status="active").first() if not new_project: - flash(_('Invalid project selected'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("Invalid project selected"), "error") + return redirect(url_for("tasks.list_tasks")) + updated_count = 0 skipped_count = 0 - + for task_id_str in task_ids: try: task_id = int(task_id_str) task = Task.query.get(task_id) - + if not task: continue - + # Check permissions if not current_user.is_admin and task.created_by != current_user.id: skipped_count += 1 continue - + # Update task project old_project_id = task.project_id task.project_id = new_project_id - + # Update related time entries to match the new project for entry in task.time_entries.all(): entry.project_id = new_project_id - + # Log activity - db.session.add(TaskActivity( - task_id=task.id, - user_id=current_user.id, - event='project_change', - details=f"Project changed from {old_project_id} to {new_project_id}" - )) - + db.session.add( + TaskActivity( + task_id=task.id, + user_id=current_user.id, + event="project_change", + details=f"Project changed from {old_project_id} to {new_project_id}", + ) + ) + updated_count += 1 - + except Exception: skipped_count += 1 - + if updated_count > 0: - if not safe_commit('bulk_move_project', {'count': updated_count, 'project_id': new_project_id}): - flash(_('Could not move tasks due to a database error'), 'error') - return redirect(url_for('tasks.list_tasks')) - - flash(f'Successfully moved {updated_count} task{"s" if updated_count != 1 else ""} to {new_project.name}', 'success') - + if not safe_commit("bulk_move_project", {"count": updated_count, "project_id": new_project_id}): + flash(_("Could not move tasks due to a database error"), "error") + return redirect(url_for("tasks.list_tasks")) + + flash( + f'Successfully moved {updated_count} task{"s" if updated_count != 1 else ""} to {new_project.name}', + "success", + ) + if skipped_count > 0: - flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', 'warning') - - return redirect(url_for('tasks.list_tasks')) + flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', "warning") + + return redirect(url_for("tasks.list_tasks")) -@tasks_bp.route('/tasks/export') +@tasks_bp.route("/tasks/export") @login_required def export_tasks(): """Export tasks to CSV""" # Get the same filters as the list view - status = request.args.get('status', '') - priority = request.args.get('priority', '') - project_id = request.args.get('project_id', type=int) - assigned_to = request.args.get('assigned_to', type=int) - search = request.args.get('search', '').strip() - overdue_param = request.args.get('overdue', '').strip().lower() - overdue = overdue_param in ['1', 'true', 'on', 'yes'] - + status = request.args.get("status", "") + priority = request.args.get("priority", "") + project_id = request.args.get("project_id", type=int) + assigned_to = request.args.get("assigned_to", type=int) + search = request.args.get("search", "").strip() + overdue_param = request.args.get("overdue", "").strip().lower() + overdue = overdue_param in ["1", "true", "on", "yes"] + query = Task.query - + # Apply filters (same as list_tasks) if status: query = query.filter_by(status=status) - + if priority: query = query.filter_by(priority=priority) - + if project_id: query = query.filter_by(project_id=project_id) - + if assigned_to: query = query.filter_by(assigned_to=assigned_to) - + if search: like = f"%{search}%" - query = query.filter( - db.or_( - Task.name.ilike(like), - Task.description.ilike(like) - ) - ) - + query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like))) + # Overdue filter if overdue: today_local = now_in_app_timezone().date() - query = query.filter( - Task.due_date < today_local, - Task.status.in_(['todo', 'in_progress', 'review']) - ) - + query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"])) + # Show user's tasks first, then others if not current_user.is_admin: - query = query.filter( - db.or_( - Task.assigned_to == current_user.id, - Task.created_by == current_user.id - ) - ) - + query = query.filter(db.or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id)) + tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() - + # Create CSV in memory output = io.StringIO() writer = csv.writer(output) - + # Write header - writer.writerow([ - 'ID', - 'Name', - 'Description', - 'Project', - 'Status', - 'Priority', - 'Assigned To', - 'Created By', - 'Due Date', - 'Estimated Hours', - 'Created At', - 'Updated At' - ]) - + writer.writerow( + [ + "ID", + "Name", + "Description", + "Project", + "Status", + "Priority", + "Assigned To", + "Created By", + "Due Date", + "Estimated Hours", + "Created At", + "Updated At", + ] + ) + # Write task data for task in tasks: - writer.writerow([ - task.id, - task.name, - task.description or '', - task.project.name if task.project else '', - task.status, - task.priority, - task.assigned_user.display_name if task.assigned_user else '', - task.creator.display_name if task.creator else '', - task.due_date.strftime('%Y-%m-%d') if task.due_date else '', - task.estimated_hours or '', - (convert_app_datetime_to_user(task.created_at, user=current_user).strftime('%Y-%m-%d %H:%M:%S') if task.created_at else ''), - (convert_app_datetime_to_user(task.updated_at, user=current_user).strftime('%Y-%m-%d %H:%M:%S') if task.updated_at else '') - ]) - + writer.writerow( + [ + task.id, + task.name, + task.description or "", + task.project.name if task.project else "", + task.status, + task.priority, + task.assigned_user.display_name if task.assigned_user else "", + task.creator.display_name if task.creator else "", + task.due_date.strftime("%Y-%m-%d") if task.due_date else "", + task.estimated_hours or "", + ( + convert_app_datetime_to_user(task.created_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S") + if task.created_at + else "" + ), + ( + convert_app_datetime_to_user(task.updated_at, user=current_user).strftime("%Y-%m-%d %H:%M:%S") + if task.updated_at + else "" + ), + ] + ) + # Create response output.seek(0) return Response( output.getvalue(), - mimetype='text/csv', + mimetype="text/csv", headers={ - 'Content-Disposition': f'attachment; filename=tasks_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' - } + "Content-Disposition": f'attachment; filename=tasks_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv' + }, ) -@tasks_bp.route('/tasks/my-tasks') +@tasks_bp.route("/tasks/my-tasks") @login_required def my_tasks(): """Show current user's tasks with filters and pagination""" - page = request.args.get('page', 1, type=int) - status = request.args.get('status', '') - priority = request.args.get('priority', '') - project_id = request.args.get('project_id', type=int) - search = request.args.get('search', '').strip() - task_type = request.args.get('task_type', '') # '', 'assigned', 'created' - overdue_param = request.args.get('overdue', '').strip().lower() - overdue = overdue_param in ['1', 'true', 'on', 'yes'] + page = request.args.get("page", 1, type=int) + status = request.args.get("status", "") + priority = request.args.get("priority", "") + project_id = request.args.get("project_id", type=int) + search = request.args.get("search", "").strip() + task_type = request.args.get("task_type", "") # '', 'assigned', 'created' + overdue_param = request.args.get("overdue", "").strip().lower() + overdue = overdue_param in ["1", "true", "on", "yes"] query = Task.query # Restrict to current user's tasks depending on task_type filter - if task_type == 'assigned': + if task_type == "assigned": query = query.filter(Task.assigned_to == current_user.id) - elif task_type == 'created': + elif task_type == "created": query = query.filter(Task.created_by == current_user.id) else: - query = query.filter( - db.or_( - Task.assigned_to == current_user.id, - Task.created_by == current_user.id - ) - ) + query = query.filter(db.or_(Task.assigned_to == current_user.id, Task.created_by == current_user.id)) # Apply filters if status: @@ -987,36 +1084,26 @@ def my_tasks(): if search: like = f"%{search}%" - query = query.filter( - db.or_( - Task.name.ilike(like), - Task.description.ilike(like) - ) - ) + query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like))) # Overdue filter (uses application's local date) if overdue: today_local = now_in_app_timezone().date() - query = query.filter( - Task.due_date < today_local, - Task.status.in_(['todo', 'in_progress', 'review']) - ) + query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"])) - tasks = query.order_by( - Task.priority.desc(), - Task.due_date.asc(), - Task.created_at.asc() - ).paginate(page=page, per_page=20, error_out=False) + tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).paginate( + page=page, per_page=20, error_out=False + ) # Provide projects for filter dropdown - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() # Force fresh kanban columns from database (no cache) db.session.expire_all() kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else [] # Prevent browser caching of kanban board response = render_template( - 'tasks/my_tasks.html', + "tasks/my_tasks.html", tasks=tasks.items, pagination=tasks, projects=projects, @@ -1026,81 +1113,86 @@ def my_tasks(): project_id=project_id, search=search, task_type=task_type, - overdue=overdue + overdue=overdue, ) resp = make_response(response) - resp.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate, max-age=0' - resp.headers['Pragma'] = 'no-cache' - resp.headers['Expires'] = '0' + resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0" + resp.headers["Pragma"] = "no-cache" + resp.headers["Expires"] = "0" return resp -@tasks_bp.route('/tasks/overdue') + +@tasks_bp.route("/tasks/overdue") @login_required def overdue_tasks(): """Show all overdue tasks""" if not current_user.is_admin: - flash(_('Only administrators can view all overdue tasks'), 'error') - return redirect(url_for('tasks.list_tasks')) - + flash(_("Only administrators can view all overdue tasks"), "error") + return redirect(url_for("tasks.list_tasks")) + tasks = Task.get_overdue_tasks() kanban_columns = KanbanColumn.get_active_columns() if KanbanColumn else [] - - return render_template('tasks/overdue.html', tasks=tasks, kanban_columns=kanban_columns) -@tasks_bp.route('/api/tasks/') + return render_template("tasks/overdue.html", tasks=tasks, kanban_columns=kanban_columns) + + +@tasks_bp.route("/api/tasks/") @login_required def api_task(task_id): """API endpoint to get task details""" task = Task.query.get_or_404(task_id) - + # Check if user has access to this task if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + return jsonify(task.to_dict()) -@tasks_bp.route('/api/tasks//status', methods=['PUT']) + +@tasks_bp.route("/api/tasks//status", methods=["PUT"]) @login_required def api_update_status(task_id): """API endpoint to update task status""" task = Task.query.get_or_404(task_id) data = request.get_json() - new_status = data.get('status', '').strip() - + new_status = data.get("status", "").strip() + # Check if user can update this task if not current_user.is_admin and task.assigned_to != current_user.id and task.created_by != current_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + # Validate status against configured kanban columns for this task's project valid_statuses = KanbanColumn.get_valid_status_keys(project_id=task.project_id) if new_status not in valid_statuses: - return jsonify({'error': 'Invalid status'}), 400 - + return jsonify({"error": "Invalid status"}), 400 + # Update status try: - if new_status == 'in_progress': - if task.status == 'done': + if new_status == "in_progress": + if task.status == "done": task.completed_at = None - task.status = 'in_progress' + task.status = "in_progress" if not task.started_at: task.started_at = now_in_app_timezone() task.updated_at = now_in_app_timezone() - if not safe_commit('api_update_task_status_reopen_in_progress', {'task_id': task.id, 'status': new_status}): - return jsonify({'error': 'Database error while updating status'}), 500 + if not safe_commit( + "api_update_task_status_reopen_in_progress", {"task_id": task.id, "status": new_status} + ): + return jsonify({"error": "Database error while updating status"}), 500 else: task.start_task() - elif new_status == 'done': + elif new_status == "done": task.complete_task() - elif new_status == 'cancelled': + elif new_status == "cancelled": task.cancel_task() else: - if task.status == 'done' and new_status in ['todo', 'review']: + if task.status == "done" and new_status in ["todo", "review"]: task.completed_at = None task.status = new_status task.updated_at = now_in_app_timezone() - if not safe_commit('api_update_task_status', {'task_id': task.id, 'status': new_status}): - return jsonify({'error': 'Database error while updating status'}), 500 - - return jsonify({'success': True, 'task': task.to_dict()}) + if not safe_commit("api_update_task_status", {"task_id": task.id, "status": new_status}): + return jsonify({"error": "Database error while updating status"}), 500 + + return jsonify({"success": True, "task": task.to_dict()}) except ValueError as e: - return jsonify({'error': str(e)}), 400 + return jsonify({"error": str(e)}), 400 diff --git a/app/routes/time_entry_templates.py b/app/routes/time_entry_templates.py index b1bb2b9d..305d7736 100644 --- a/app/routes/time_entry_templates.py +++ b/app/routes/time_entry_templates.py @@ -18,63 +18,57 @@ logger = logging.getLogger(__name__) -time_entry_templates_bp = Blueprint('time_entry_templates', __name__) +time_entry_templates_bp = Blueprint("time_entry_templates", __name__) -@time_entry_templates_bp.route('/templates') +@time_entry_templates_bp.route("/templates") @login_required def list_templates(): """List all time entry templates for the current user.""" - templates = TimeEntryTemplate.query.filter_by( - user_id=current_user.id - ).order_by(desc(TimeEntryTemplate.last_used_at)).all() - - return render_template( - 'time_entry_templates/list.html', - templates=templates + templates = ( + TimeEntryTemplate.query.filter_by(user_id=current_user.id).order_by(desc(TimeEntryTemplate.last_used_at)).all() ) + return render_template("time_entry_templates/list.html", templates=templates) -@time_entry_templates_bp.route('/templates/create', methods=['GET', 'POST']) + +@time_entry_templates_bp.route("/templates/create", methods=["GET", "POST"]) @login_required def create_template(): """Create a new time entry template.""" - if request.method == 'POST': - name = request.form.get('name', '').strip() - project_id = request.form.get('project_id') - task_id = request.form.get('task_id') - default_duration = request.form.get('default_duration') - default_notes = request.form.get('default_notes', '').strip() - tags = request.form.get('tags', '').strip() - + if request.method == "POST": + name = request.form.get("name", "").strip() + project_id = request.form.get("project_id") + task_id = request.form.get("task_id") + default_duration = request.form.get("default_duration") + default_notes = request.form.get("default_notes", "").strip() + tags = request.form.get("tags", "").strip() + # Validation if not name: - flash(_('Template name is required'), 'error') + flash(_("Template name is required"), "error") return render_template( - 'time_entry_templates/create.html', - projects=Project.query.filter_by(status='active').order_by(Project.name).all() + "time_entry_templates/create.html", + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), ) - + # Check for duplicate name - existing = TimeEntryTemplate.query.filter_by( - user_id=current_user.id, - name=name - ).first() - + existing = TimeEntryTemplate.query.filter_by(user_id=current_user.id, name=name).first() + if existing: - flash(f'Template "{name}" already exists', 'error') + flash(f'Template "{name}" already exists', "error") return render_template( - 'time_entry_templates/create.html', - projects=Project.query.filter_by(status='active').order_by(Project.name).all(), - form_data=request.form + "time_entry_templates/create.html", + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), + form_data=request.form, ) - + # Convert duration to float try: default_duration = float(default_duration) if default_duration else None except ValueError: default_duration = None - + # Create template template = TimeEntryTemplate( user_id=current_user.id, @@ -83,116 +77,105 @@ def create_template(): task_id=int(task_id) if task_id else None, default_duration=default_duration, default_notes=default_notes if default_notes else None, - tags=tags if tags else None + tags=tags if tags else None, ) - + db.session.add(template) - if not safe_commit('create_time_entry_template', {'name': name}): - flash(_('Could not create template due to a database error'), 'error') + if not safe_commit("create_time_entry_template", {"name": name}): + flash(_("Could not create template due to a database error"), "error") return render_template( - 'time_entry_templates/create.html', - projects=Project.query.filter_by(status='active').order_by(Project.name).all(), - form_data=request.form + "time_entry_templates/create.html", + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), + form_data=request.form, ) - + # Log activity Activity.log( user_id=current_user.id, - action='created', - entity_type='time_entry_template', + action="created", + entity_type="time_entry_template", entity_id=template.id, entity_name=template.name, description=f'Created time entry template "{template.name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event - log_event("time_entry_template.created", - user_id=current_user.id, - template_id=template.id, - template_name=name) - track_event(current_user.id, "time_entry_template.created", { - "template_id": template.id, - "template_name": name, - "has_project": bool(project_id), - "has_task": bool(task_id), - "has_default_duration": bool(default_duration) - }) - - flash(f'Template "{name}" created successfully', 'success') - return redirect(url_for('time_entry_templates.list_templates')) - + log_event("time_entry_template.created", user_id=current_user.id, template_id=template.id, template_name=name) + track_event( + current_user.id, + "time_entry_template.created", + { + "template_id": template.id, + "template_name": name, + "has_project": bool(project_id), + "has_task": bool(task_id), + "has_default_duration": bool(default_duration), + }, + ) + + flash(f'Template "{name}" created successfully', "success") + return redirect(url_for("time_entry_templates.list_templates")) + # GET request - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template( - 'time_entry_templates/create.html', - projects=projects - ) + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("time_entry_templates/create.html", projects=projects) -@time_entry_templates_bp.route('/templates/') +@time_entry_templates_bp.route("/templates/") @login_required def view_template(template_id): """View a specific template.""" - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first_or_404() - - return render_template( - 'time_entry_templates/view.html', - template=template - ) + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first_or_404() + return render_template("time_entry_templates/view.html", template=template) -@time_entry_templates_bp.route('/templates//edit', methods=['GET', 'POST']) + +@time_entry_templates_bp.route("/templates//edit", methods=["GET", "POST"]) @login_required def edit_template(template_id): """Edit an existing time entry template.""" - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first_or_404() - - if request.method == 'POST': - name = request.form.get('name', '').strip() - project_id = request.form.get('project_id') - task_id = request.form.get('task_id') - default_duration = request.form.get('default_duration') - default_notes = request.form.get('default_notes', '').strip() - tags = request.form.get('tags', '').strip() - + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first_or_404() + + if request.method == "POST": + name = request.form.get("name", "").strip() + project_id = request.form.get("project_id") + task_id = request.form.get("task_id") + default_duration = request.form.get("default_duration") + default_notes = request.form.get("default_notes", "").strip() + tags = request.form.get("tags", "").strip() + # Validation if not name: - flash(_('Template name is required'), 'error') + flash(_("Template name is required"), "error") return render_template( - 'time_entry_templates/edit.html', + "time_entry_templates/edit.html", template=template, - projects=Project.query.filter_by(status='active').order_by(Project.name).all() + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), ) - + # Check for duplicate name (excluding current template) existing = TimeEntryTemplate.query.filter( TimeEntryTemplate.user_id == current_user.id, TimeEntryTemplate.name == name, - TimeEntryTemplate.id != template_id + TimeEntryTemplate.id != template_id, ).first() - + if existing: - flash(f'Template "{name}" already exists', 'error') + flash(f'Template "{name}" already exists', "error") return render_template( - 'time_entry_templates/edit.html', + "time_entry_templates/edit.html", template=template, - projects=Project.query.filter_by(status='active').order_by(Project.name).all() + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), ) - + # Convert duration to float try: default_duration = float(default_duration) if default_duration else None except ValueError: default_duration = None - + # Update template old_name = template.name template.name = name @@ -201,151 +184,123 @@ def edit_template(template_id): template.default_duration = default_duration template.default_notes = default_notes if default_notes else None template.tags = tags if tags else None - - if not safe_commit('update_time_entry_template', {'template_id': template_id}): - flash(_('Could not update template due to a database error'), 'error') + + if not safe_commit("update_time_entry_template", {"template_id": template_id}): + flash(_("Could not update template due to a database error"), "error") return render_template( - 'time_entry_templates/edit.html', + "time_entry_templates/edit.html", template=template, - projects=Project.query.filter_by(status='active').order_by(Project.name).all() + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), ) - + # Log activity Activity.log( user_id=current_user.id, - action='updated', - entity_type='time_entry_template', + action="updated", + entity_type="time_entry_template", entity_id=template.id, entity_name=template.name, description=f'Updated time entry template "{template.name}"', - extra_data={'old_name': old_name} if old_name != name else None, + extra_data={"old_name": old_name} if old_name != name else None, ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event - log_event("time_entry_template.updated", - user_id=current_user.id, - template_id=template.id) - track_event(current_user.id, "time_entry_template.updated", { - "template_id": template.id, - "template_name": name - }) - - flash(f'Template "{name}" updated successfully', 'success') - return redirect(url_for('time_entry_templates.list_templates')) - + log_event("time_entry_template.updated", user_id=current_user.id, template_id=template.id) + track_event(current_user.id, "time_entry_template.updated", {"template_id": template.id, "template_name": name}) + + flash(f'Template "{name}" updated successfully', "success") + return redirect(url_for("time_entry_templates.list_templates")) + # GET request - projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template( - 'time_entry_templates/edit.html', - template=template, - projects=projects - ) + projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("time_entry_templates/edit.html", template=template, projects=projects) -@time_entry_templates_bp.route('/templates//delete', methods=['POST']) +@time_entry_templates_bp.route("/templates//delete", methods=["POST"]) @login_required def delete_template(template_id): """Delete a time entry template.""" - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first_or_404() - + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first_or_404() + template_name = template.name - + db.session.delete(template) - if not safe_commit('delete_time_entry_template', {'template_id': template_id}): - flash(_('Could not delete template due to a database error'), 'error') - return redirect(url_for('time_entry_templates.list_templates')) - + if not safe_commit("delete_time_entry_template", {"template_id": template_id}): + flash(_("Could not delete template due to a database error"), "error") + return redirect(url_for("time_entry_templates.list_templates")) + # Log activity Activity.log( user_id=current_user.id, - action='deleted', - entity_type='time_entry_template', + action="deleted", + entity_type="time_entry_template", entity_id=template_id, entity_name=template_name, description=f'Deleted time entry template "{template_name}"', ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track event - log_event("time_entry_template.deleted", - user_id=current_user.id, - template_id=template_id, - template_name=template_name) - track_event(current_user.id, "time_entry_template.deleted", { - "template_id": template_id, - "template_name": template_name - }) - - flash(f'Template "{template_name}" deleted successfully', 'success') - return redirect(url_for('time_entry_templates.list_templates')) - - -@time_entry_templates_bp.route('/api/templates', methods=['GET']) + log_event( + "time_entry_template.deleted", user_id=current_user.id, template_id=template_id, template_name=template_name + ) + track_event( + current_user.id, "time_entry_template.deleted", {"template_id": template_id, "template_name": template_name} + ) + + flash(f'Template "{template_name}" deleted successfully', "success") + return redirect(url_for("time_entry_templates.list_templates")) + + +@time_entry_templates_bp.route("/api/templates", methods=["GET"]) @login_required def get_templates_api(): """Get templates as JSON (for AJAX requests).""" - templates = TimeEntryTemplate.query.filter_by( - user_id=current_user.id - ).order_by(desc(TimeEntryTemplate.last_used_at)).all() - - return jsonify({ - 'templates': [t.to_dict() for t in templates] - }) + templates = ( + TimeEntryTemplate.query.filter_by(user_id=current_user.id).order_by(desc(TimeEntryTemplate.last_used_at)).all() + ) + + return jsonify({"templates": [t.to_dict() for t in templates]}) -@time_entry_templates_bp.route('/api/templates/', methods=['GET']) +@time_entry_templates_bp.route("/api/templates/", methods=["GET"]) @login_required def get_template_api(template_id): """Get a specific template as JSON.""" - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first_or_404() - + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first_or_404() + return jsonify(template.to_dict()) -@time_entry_templates_bp.route('/api/templates//use', methods=['POST']) +@time_entry_templates_bp.route("/api/templates//use", methods=["POST"]) @login_required def use_template_api(template_id): """Mark template as used and update last_used_at.""" - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first_or_404() - + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first_or_404() + template.record_usage() - - if not safe_commit('use_time_entry_template', {'template_id': template_id}): - return jsonify({'error': 'Could not record template usage'}), 500 - + + if not safe_commit("use_time_entry_template", {"template_id": template_id}): + return jsonify({"error": "Could not record template usage"}), 500 + # Track event - log_event("time_entry_template.used", - user_id=current_user.id, - template_id=template.id, - template_name=template.name) - track_event(current_user.id, "time_entry_template.used", { - "template_id": template.id, - "template_name": template.name, - "usage_count": template.usage_count - }) - - return jsonify({ - 'success': True, - 'template': template.to_dict() - }) - - -@time_entry_templates_bp.route('/api/projects//tasks', methods=['GET']) + log_event("time_entry_template.used", user_id=current_user.id, template_id=template.id, template_name=template.name) + track_event( + current_user.id, + "time_entry_template.used", + {"template_id": template.id, "template_name": template.name, "usage_count": template.usage_count}, + ) + + return jsonify({"success": True, "template": template.to_dict()}) + + +@time_entry_templates_bp.route("/api/projects//tasks", methods=["GET"]) @login_required def get_project_tasks_api(project_id): """Deprecated: use main API endpoint at /api/projects//tasks""" from app.routes.api import get_project_tasks as _api_get_project_tasks - return _api_get_project_tasks(project_id) + return _api_get_project_tasks(project_id) diff --git a/app/routes/timer.py b/app/routes/timer.py index d938a036..2f45e5cb 100644 --- a/app/routes/timer.py +++ b/app/routes/timer.py @@ -7,30 +7,32 @@ from datetime import datetime, timedelta import json from app.utils.db import safe_commit -from app.utils.posthog_funnels import ( - track_onboarding_first_timer, - track_onboarding_first_time_entry -) +from app.utils.posthog_funnels import track_onboarding_first_timer, track_onboarding_first_time_entry -timer_bp = Blueprint('timer', __name__) +timer_bp = Blueprint("timer", __name__) -@timer_bp.route('/timer/start', methods=['POST']) + +@timer_bp.route("/timer/start", methods=["POST"]) @login_required def start_timer(): """Start a new timer for the current user""" - project_id = request.form.get('project_id', type=int) - task_id = request.form.get('task_id', type=int) - notes = request.form.get('notes', '').strip() - template_id = request.form.get('template_id', type=int) - current_app.logger.info("POST /timer/start user=%s project_id=%s task_id=%s template_id=%s", current_user.username, project_id, task_id, template_id) - + project_id = request.form.get("project_id", type=int) + task_id = request.form.get("task_id", type=int) + notes = request.form.get("notes", "").strip() + template_id = request.form.get("template_id", type=int) + current_app.logger.info( + "POST /timer/start user=%s project_id=%s task_id=%s template_id=%s", + current_user.username, + project_id, + task_id, + template_id, + ) + # Load template data if template_id is provided if template_id: from app.models import TimeEntryTemplate - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first() + + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first() if template: # Override with template values if not explicitly set if not project_id and template.project_id: @@ -42,149 +44,150 @@ def start_timer(): # Mark template as used template.record_usage() db.session.commit() - + if not project_id: - flash(_('Project is required'), 'error') + flash(_("Project is required"), "error") current_app.logger.warning("Start timer failed: missing project_id") - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Check if project exists project = Project.query.get(project_id) if not project: - flash(_('Invalid project selected'), 'error') + flash(_("Invalid project selected"), "error") current_app.logger.warning("Start timer failed: invalid project_id=%s", project_id) - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Check if project is active (not archived or inactive) - if project.status == 'archived': - flash(_('Cannot start timer for an archived project. Please unarchive the project first.'), 'error') + if project.status == "archived": + flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error") current_app.logger.warning("Start timer failed: project_id=%s is archived", project_id) - return redirect(url_for('main.dashboard')) - elif project.status != 'active': - flash(_('Cannot start timer for an inactive project'), 'error') + return redirect(url_for("main.dashboard")) + elif project.status != "active": + flash(_("Cannot start timer for an inactive project"), "error") current_app.logger.warning("Start timer failed: project_id=%s is not active", project_id) - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # If a task is provided, validate it belongs to the project if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - flash(_('Selected task is invalid for the chosen project'), 'error') - current_app.logger.warning("Start timer failed: task_id=%s does not belong to project_id=%s", task_id, project_id) - return redirect(url_for('main.dashboard')) + flash(_("Selected task is invalid for the chosen project"), "error") + current_app.logger.warning( + "Start timer failed: task_id=%s does not belong to project_id=%s", task_id, project_id + ) + return redirect(url_for("main.dashboard")) else: task = None - + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - flash(_('You already have an active timer. Stop it before starting a new one.'), 'error') + flash(_("You already have an active timer. Stop it before starting a new one."), "error") current_app.logger.info("Start timer blocked: user already has an active timer") - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Create new timer from app.models.time_entry import local_now + new_timer = TimeEntry( user_id=current_user.id, project_id=project_id, task_id=task.id if task else None, start_time=local_now(), notes=notes if notes else None, - source='auto' + source="auto", ) - + db.session.add(new_timer) - if not safe_commit('start_timer', {'user_id': current_user.id, 'project_id': project_id, 'task_id': task_id}): - flash(_('Could not start timer due to a database error. Please check server logs.'), 'error') - return redirect(url_for('main.dashboard')) - current_app.logger.info("Started new timer id=%s for user=%s project_id=%s task_id=%s", new_timer.id, current_user.username, project_id, task_id) - + if not safe_commit("start_timer", {"user_id": current_user.id, "project_id": project_id, "task_id": task_id}): + flash(_("Could not start timer due to a database error. Please check server logs."), "error") + return redirect(url_for("main.dashboard")) + current_app.logger.info( + "Started new timer id=%s for user=%s project_id=%s task_id=%s", + new_timer.id, + current_user.username, + project_id, + task_id, + ) + # Track timer started event log_event("timer.started", user_id=current_user.id, project_id=project_id, task_id=task_id, description=notes) - track_event(current_user.id, "timer.started", { - "project_id": project_id, - "task_id": task_id, - "has_description": bool(notes) - }) - + track_event( + current_user.id, "timer.started", {"project_id": project_id, "task_id": task_id, "has_description": bool(notes)} + ) + # Log activity Activity.log( user_id=current_user.id, - action='started', - entity_type='time_entry', + action="started", + entity_type="time_entry", entity_id=new_timer.id, - entity_name=f'{project.name}' + (f' - {task.name}' if task else ''), - description=f'Started timer for {project.name}' + (f' - {task.name}' if task else ''), - extra_data={'project_id': project_id, 'task_id': task_id}, + entity_name=f"{project.name}" + (f" - {task.name}" if task else ""), + description=f"Started timer for {project.name}" + (f" - {task.name}" if task else ""), + extra_data={"project_id": project_id, "task_id": task_id}, ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Check if this is user's first timer (onboarding milestone) - timer_count = TimeEntry.query.filter_by( - user_id=current_user.id, - source='auto' - ).count() - + timer_count = TimeEntry.query.filter_by(user_id=current_user.id, source="auto").count() + if timer_count == 1: # First timer ever - track_onboarding_first_timer(current_user.id, { - "project_id": project_id, - "has_task": bool(task_id), - "has_notes": bool(notes) - }) - + track_onboarding_first_timer( + current_user.id, {"project_id": project_id, "has_task": bool(task_id), "has_notes": bool(notes)} + ) + # Emit WebSocket event for real-time updates try: payload = { - 'user_id': current_user.id, - 'timer_id': new_timer.id, - 'project_name': project.name, - 'start_time': new_timer.start_time.isoformat() + "user_id": current_user.id, + "timer_id": new_timer.id, + "project_name": project.name, + "start_time": new_timer.start_time.isoformat(), } if task: - payload['task_id'] = task.id - payload['task_name'] = task.name - socketio.emit('timer_started', payload) + payload["task_id"] = task.id + payload["task_name"] = task.name + socketio.emit("timer_started", payload) except Exception as e: current_app.logger.warning("Socket emit failed for timer_started: %s", e) - + if task: - flash(f'Timer started for {project.name} - {task.name}', 'success') + flash(f"Timer started for {project.name} - {task.name}", "success") else: - flash(f'Timer started for {project.name}', 'success') - return redirect(url_for('main.dashboard')) + flash(f"Timer started for {project.name}", "success") + return redirect(url_for("main.dashboard")) + -@timer_bp.route('/timer/start/from-template/', methods=['GET', 'POST']) +@timer_bp.route("/timer/start/from-template/", methods=["GET", "POST"]) @login_required def start_timer_from_template(template_id): """Start a timer directly from a template""" from app.models import TimeEntryTemplate - + # Load template - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first_or_404() - + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first_or_404() + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - flash(_('You already have an active timer. Stop it before starting a new one.'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("You already have an active timer. Stop it before starting a new one."), "error") + return redirect(url_for("main.dashboard")) + # Validate template has required data if not template.project_id: - flash(_('Template must have a project to start a timer'), 'error') - return redirect(url_for('time_entry_templates.list_templates')) - + flash(_("Template must have a project to start a timer"), "error") + return redirect(url_for("time_entry_templates.list_templates")) + # Check if project is active project = Project.query.get(template.project_id) - if not project or project.status != 'active': - flash(_('Cannot start timer for this project'), 'error') - return redirect(url_for('time_entry_templates.list_templates')) - + if not project or project.status != "active": + flash(_("Cannot start timer for this project"), "error") + return redirect(url_for("time_entry_templates.list_templates")) + # Create new timer from template from app.models.time_entry import local_now + new_timer = TimeEntry( user_id=current_user.id, project_id=template.project_id, @@ -192,434 +195,512 @@ def start_timer_from_template(template_id): start_time=local_now(), notes=template.default_notes, tags=template.tags, - source='auto', - billable=template.billable + source="auto", + billable=template.billable, ) - + db.session.add(new_timer) - + # Mark template as used template.record_usage() - - if not safe_commit('start_timer_from_template', {'template_id': template_id}): - flash(_('Could not start timer due to a database error. Please check server logs.'), 'error') - return redirect(url_for('time_entry_templates.list_templates')) - + + if not safe_commit("start_timer_from_template", {"template_id": template_id}): + flash(_("Could not start timer due to a database error. Please check server logs."), "error") + return redirect(url_for("time_entry_templates.list_templates")) + # Track events - log_event("timer.started.from_template", - user_id=current_user.id, - template_id=template_id, - project_id=template.project_id) - track_event(current_user.id, "timer.started.from_template", { - "template_id": template_id, - "template_name": template.name, - "project_id": template.project_id, - "has_task": bool(template.task_id) - }) - - flash(f'Timer started from template "{template.name}"', 'success') - return redirect(url_for('main.dashboard')) - -@timer_bp.route('/timer/start/') + log_event( + "timer.started.from_template", user_id=current_user.id, template_id=template_id, project_id=template.project_id + ) + track_event( + current_user.id, + "timer.started.from_template", + { + "template_id": template_id, + "template_name": template.name, + "project_id": template.project_id, + "has_task": bool(template.task_id), + }, + ) + + flash(f'Timer started from template "{template.name}"', "success") + return redirect(url_for("main.dashboard")) + + +@timer_bp.route("/timer/start/") @login_required def start_timer_for_project(project_id): """Start a timer for a specific project (GET route for direct links)""" - task_id = request.args.get('task_id', type=int) + task_id = request.args.get("task_id", type=int) current_app.logger.info("GET /timer/start/%s user=%s task_id=%s", project_id, current_user.username, task_id) - + # Check if project exists project = Project.query.get(project_id) if not project: - flash(_('Invalid project selected'), 'error') + flash(_("Invalid project selected"), "error") current_app.logger.warning("Start timer (GET) failed: invalid project_id=%s", project_id) - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Check if project is active (not archived or inactive) - if project.status == 'archived': - flash(_('Cannot start timer for an archived project. Please unarchive the project first.'), 'error') + if project.status == "archived": + flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error") current_app.logger.warning("Start timer (GET) failed: project_id=%s is archived", project_id) - return redirect(url_for('main.dashboard')) - elif project.status != 'active': - flash(_('Cannot start timer for an inactive project'), 'error') + return redirect(url_for("main.dashboard")) + elif project.status != "active": + flash(_("Cannot start timer for an inactive project"), "error") current_app.logger.warning("Start timer (GET) failed: project_id=%s is not active", project_id) - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - flash(_('You already have an active timer. Stop it before starting a new one.'), 'error') + flash(_("You already have an active timer. Stop it before starting a new one."), "error") current_app.logger.info("Start timer (GET) blocked: user already has an active timer") - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Create new timer from app.models.time_entry import local_now + new_timer = TimeEntry( - user_id=current_user.id, - project_id=project_id, - task_id=task_id, - start_time=local_now(), - source='auto' + user_id=current_user.id, project_id=project_id, task_id=task_id, start_time=local_now(), source="auto" ) - + db.session.add(new_timer) - if not safe_commit('start_timer_for_project', {'user_id': current_user.id, 'project_id': project_id, 'task_id': task_id}): - flash(_('Could not start timer due to a database error. Please check server logs.'), 'error') - return redirect(url_for('main.dashboard')) - current_app.logger.info("Started new timer id=%s for user=%s project_id=%s task_id=%s", new_timer.id, current_user.username, project_id, task_id) - + if not safe_commit( + "start_timer_for_project", {"user_id": current_user.id, "project_id": project_id, "task_id": task_id} + ): + flash(_("Could not start timer due to a database error. Please check server logs."), "error") + return redirect(url_for("main.dashboard")) + current_app.logger.info( + "Started new timer id=%s for user=%s project_id=%s task_id=%s", + new_timer.id, + current_user.username, + project_id, + task_id, + ) + # Emit WebSocket event for real-time updates try: - socketio.emit('timer_started', { - 'user_id': current_user.id, - 'timer_id': new_timer.id, - 'project_name': project.name, - 'task_id': task_id, - 'start_time': new_timer.start_time.isoformat() - }) + socketio.emit( + "timer_started", + { + "user_id": current_user.id, + "timer_id": new_timer.id, + "project_name": project.name, + "task_id": task_id, + "start_time": new_timer.start_time.isoformat(), + }, + ) except Exception as e: current_app.logger.warning("Socket emit failed for timer_started (GET): %s", e) - + if task_id: task = Task.query.get(task_id) task_name = task.name if task else "Unknown Task" - flash(f'Timer started for {project.name} - {task_name}', 'success') + flash(f"Timer started for {project.name} - {task_name}", "success") else: - flash(f'Timer started for {project.name}', 'success') - - return redirect(url_for('main.dashboard')) + flash(f"Timer started for {project.name}", "success") + + return redirect(url_for("main.dashboard")) + -@timer_bp.route('/timer/stop', methods=['POST']) +@timer_bp.route("/timer/stop", methods=["POST"]) @login_required def stop_timer(): """Stop the current user's active timer""" active_timer = current_user.active_timer current_app.logger.info("POST /timer/stop user=%s active_timer=%s", current_user.username, bool(active_timer)) - + if not active_timer: - flash(_('No active timer to stop'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("No active timer to stop"), "error") + return redirect(url_for("main.dashboard")) + # Stop the timer try: active_timer.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 - log_event("timer.stopped", - user_id=current_user.id, - time_entry_id=active_timer.id, - project_id=active_timer.project_id, - task_id=active_timer.task_id, - duration_seconds=duration_seconds) - track_event(current_user.id, "timer.stopped", { - "time_entry_id": active_timer.id, - "project_id": active_timer.project_id, - "task_id": active_timer.task_id, - "duration_seconds": duration_seconds - }) - + duration_seconds = active_timer.duration if hasattr(active_timer, "duration") else 0 + log_event( + "timer.stopped", + user_id=current_user.id, + time_entry_id=active_timer.id, + project_id=active_timer.project_id, + task_id=active_timer.task_id, + duration_seconds=duration_seconds, + ) + track_event( + current_user.id, + "timer.stopped", + { + "time_entry_id": active_timer.id, + "project_id": active_timer.project_id, + "task_id": active_timer.task_id, + "duration_seconds": duration_seconds, + }, + ) + # Log activity - project_name = active_timer.project.name if active_timer.project else 'No project' + project_name = active_timer.project.name if active_timer.project else "No project" task_name = active_timer.task.name if active_timer.task else None Activity.log( user_id=current_user.id, - action='stopped', - entity_type='time_entry', + action="stopped", + entity_type="time_entry", entity_id=active_timer.id, - entity_name=f'{project_name}' + (f' - {task_name}' if task_name else ''), - description=f'Stopped timer for {project_name}' + (f' - {task_name}' if task_name else '') + f' - Duration: {active_timer.duration_formatted}', - extra_data={'duration_hours': active_timer.duration_hours, 'project_id': active_timer.project_id, 'task_id': active_timer.task_id}, + entity_name=f"{project_name}" + (f" - {task_name}" if task_name else ""), + description=f"Stopped timer for {project_name}" + + (f" - {task_name}" if task_name else "") + + f" - Duration: {active_timer.duration_formatted}", + extra_data={ + "duration_hours": active_timer.duration_hours, + "project_id": active_timer.project_id, + "task_id": active_timer.task_id, + }, ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Check if this is user's first completed time entry (onboarding milestone) - entry_count = TimeEntry.query.filter_by( - user_id=current_user.id - ).filter( - TimeEntry.end_time.isnot(None) - ).count() - + entry_count = TimeEntry.query.filter_by(user_id=current_user.id).filter(TimeEntry.end_time.isnot(None)).count() + if entry_count == 1: # First completed time entry ever - track_onboarding_first_time_entry(current_user.id, { - "source": "timer", - "duration_seconds": duration_seconds, - "has_task": bool(active_timer.task_id) - }) + track_onboarding_first_time_entry( + current_user.id, + {"source": "timer", "duration_seconds": duration_seconds, "has_task": bool(active_timer.task_id)}, + ) 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 - }) + 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) - - flash(f'Timer stopped. Duration: {active_timer.duration_formatted}', 'success') - return redirect(url_for('main.dashboard')) -@timer_bp.route('/timer/status') + flash(f"Timer stopped. Duration: {active_timer.duration_formatted}", "success") + return redirect(url_for("main.dashboard")) + + +@timer_bp.route("/timer/status") @login_required def timer_status(): """Get current timer status as JSON""" active_timer = current_user.active_timer - + if not active_timer: - return jsonify({ - 'active': False, - 'timer': None - }) - - return jsonify({ - 'active': True, - 'timer': { - 'id': active_timer.id, - 'project_name': active_timer.project.name, - 'start_time': active_timer.start_time.isoformat(), - 'current_duration': active_timer.current_duration_seconds, - 'duration_formatted': active_timer.duration_formatted + return jsonify({"active": False, "timer": None}) + + return jsonify( + { + "active": True, + "timer": { + "id": active_timer.id, + "project_name": active_timer.project.name, + "start_time": active_timer.start_time.isoformat(), + "current_duration": active_timer.current_duration_seconds, + "duration_formatted": active_timer.duration_formatted, + }, } - }) + ) -@timer_bp.route('/timer/edit/', methods=['GET', 'POST']) + +@timer_bp.route("/timer/edit/", methods=["GET", "POST"]) @login_required def edit_timer(timer_id): """Edit a completed timer entry""" timer = TimeEntry.query.get_or_404(timer_id) - + # Check if user can edit this timer if timer.user_id != current_user.id and not current_user.is_admin: - flash(_('You can only edit your own timers'), 'error') - return redirect(url_for('main.dashboard')) - - if request.method == 'POST': + flash(_("You can only edit your own timers"), "error") + 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.notes = request.form.get("notes", "").strip() + timer.tags = request.form.get("tags", "").strip() + timer.billable = request.form.get("billable") == "on" + # Admin users can edit additional fields if current_user.is_admin: # Update project if changed - new_project_id = request.form.get('project_id', type=int) + new_project_id = request.form.get("project_id", type=int) if new_project_id and new_project_id != timer.project_id: - new_project = Project.query.filter_by(id=new_project_id, status='active').first() + new_project = Project.query.filter_by(id=new_project_id, status="active").first() if new_project: timer.project_id = new_project_id else: - flash(_('Invalid project selected'), 'error') - return render_template('timer/edit_timer.html', timer=timer, - projects=Project.query.filter_by(status='active').order_by(Project.name).all(), - tasks=[] if not new_project_id else Task.query.filter_by(project_id=new_project_id).order_by(Task.name).all()) - + flash(_("Invalid project selected"), "error") + return render_template( + "timer/edit_timer.html", + timer=timer, + projects=Project.query.filter_by(status="active").order_by(Project.name).all(), + tasks=( + [] + if not new_project_id + else Task.query.filter_by(project_id=new_project_id).order_by(Task.name).all() + ), + ) + # Update task if changed - new_task_id = request.form.get('task_id', type=int) + 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() if new_task: timer.task_id = new_task_id else: - flash(_('Invalid task selected for the chosen project'), 'error') - return render_template('timer/edit_timer.html', timer=timer, - 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()) + flash(_("Invalid task selected for the chosen project"), "error") + return render_template( + "timer/edit_timer.html", + timer=timer, + 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: timer.task_id = None - + # Update start and end times if provided - start_date = request.form.get('start_date') - start_time = request.form.get('start_time') - end_date = request.form.get('end_date') - end_time = request.form.get('end_time') - + start_date = request.form.get("start_date") + start_time = request.form.get("start_time") + end_date = request.form.get("end_date") + end_time = request.form.get("end_time") + if start_date and start_time: try: # Convert parsed UTC-aware to local naive to match model storage parsed_start_utc = parse_local_datetime(start_date, start_time) new_start_time = utc_to_local(parsed_start_utc).replace(tzinfo=None) - + # Validate that start time is not in the future from app.models.time_entry import local_now + current_time = local_now() if new_start_time > current_time: - flash(_('Start time cannot be in the future'), 'error') - return render_template('timer/edit_timer.html', timer=timer, - 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()) - + flash(_("Start time cannot be in the future"), "error") + return render_template( + "timer/edit_timer.html", + timer=timer, + 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(), + ) + timer.start_time = new_start_time except ValueError: - flash(_('Invalid start date/time format'), 'error') - return render_template('timer/edit_timer.html', timer=timer, - 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()) - + flash(_("Invalid start date/time format"), "error") + return render_template( + "timer/edit_timer.html", + timer=timer, + 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(), + ) + if end_date and end_time: try: # Convert parsed UTC-aware to local naive to match model storage parsed_end_utc = parse_local_datetime(end_date, end_time) 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: - flash(_('End time must be after start time'), 'error') - return render_template('timer/edit_timer.html', timer=timer, - 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()) - + flash(_("End time must be after start time"), "error") + return render_template( + "timer/edit_timer.html", + timer=timer, + 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(), + ) + timer.end_time = new_end_time # Recalculate duration timer.calculate_duration() except ValueError: - flash(_('Invalid end date/time format'), 'error') - return render_template('timer/edit_timer.html', timer=timer, - 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()) - + flash(_("Invalid end date/time format"), "error") + return render_template( + "timer/edit_timer.html", + timer=timer, + 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(), + ) + # Update source if provided - new_source = request.form.get('source') - if new_source in ['manual', 'auto']: + 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')) - - flash(_('Timer updated successfully'), 'success') - return redirect(url_for('main.dashboard')) - + + 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")) + + flash(_("Timer updated successfully"), "success") + return redirect(url_for("main.dashboard")) + # Get projects and tasks for admin users projects = [] tasks = [] if current_user.is_admin: - projects = Project.query.filter_by(status='active').order_by(Project.name).all() + projects = Project.query.filter_by(status="active").order_by(Project.name).all() if timer.project_id: tasks = Task.query.filter_by(project_id=timer.project_id).order_by(Task.name).all() - - return render_template('timer/edit_timer.html', timer=timer, projects=projects, tasks=tasks) -@timer_bp.route('/timer/delete/', methods=['POST']) + return render_template("timer/edit_timer.html", timer=timer, projects=projects, tasks=tasks) + + +@timer_bp.route("/timer/delete/", methods=["POST"]) @login_required def delete_timer(timer_id): """Delete a timer entry""" timer = TimeEntry.query.get_or_404(timer_id) - + # Check if user can delete this timer if timer.user_id != current_user.id and not current_user.is_admin: - flash(_('You can only delete your own timers'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("You can only delete your own timers"), "error") + return redirect(url_for("main.dashboard")) + # Don't allow deletion of active timers if timer.is_active: - flash(_('Cannot delete an active timer'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("Cannot delete an active timer"), "error") + return redirect(url_for("main.dashboard")) + project_name = timer.project.name db.session.delete(timer) - if not safe_commit('delete_timer', {'timer_id': timer.id}): - flash(_('Could not delete timer due to a database error. Please check server logs.'), 'error') - return redirect(url_for('main.dashboard')) - - flash(f'Timer for {project_name} deleted successfully', 'success') - return redirect(url_for('main.dashboard')) - -@timer_bp.route('/timer/manual', methods=['GET', 'POST']) + if not safe_commit("delete_timer", {"timer_id": timer.id}): + flash(_("Could not delete timer due to a database error. Please check server logs."), "error") + return redirect(url_for("main.dashboard")) + + flash(f"Timer for {project_name} deleted successfully", "success") + return redirect(url_for("main.dashboard")) + + +@timer_bp.route("/timer/manual", methods=["GET", "POST"]) @login_required def manual_entry(): """Create a manual time entry""" # Get active projects for dropdown (used for both GET and error re-renders on POST) - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Get project_id and task_id from query parameters for pre-filling - project_id = request.args.get('project_id', type=int) - task_id = request.args.get('task_id', type=int) - template_id = request.args.get('template', type=int) - + project_id = request.args.get("project_id", type=int) + task_id = request.args.get("task_id", type=int) + template_id = request.args.get("template", type=int) + # Load template data if template_id is provided template_data = None if template_id: from app.models import TimeEntryTemplate - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=current_user.id - ).first() + + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=current_user.id).first() if template: template_data = { - 'project_id': template.project_id, - 'task_id': template.task_id, - 'notes': template.default_notes, - 'tags': template.tags, - 'billable': template.billable + "project_id": template.project_id, + "task_id": template.task_id, + "notes": template.default_notes, + "tags": template.tags, + "billable": template.billable, } # Override with template values if not explicitly set if not project_id and template.project_id: project_id = template.project_id if not task_id and template.task_id: task_id = template.task_id - - if request.method == 'POST': - project_id = request.form.get('project_id', type=int) - task_id = request.form.get('task_id', type=int) - start_date = request.form.get('start_date') - start_time = request.form.get('start_time') - end_date = request.form.get('end_date') - end_time = request.form.get('end_time') - notes = request.form.get('notes', '').strip() - tags = request.form.get('tags', '').strip() - billable = request.form.get('billable') == 'on' - + + if request.method == "POST": + project_id = request.form.get("project_id", type=int) + task_id = request.form.get("task_id", type=int) + start_date = request.form.get("start_date") + start_time = request.form.get("start_time") + end_date = request.form.get("end_date") + end_time = request.form.get("end_time") + notes = request.form.get("notes", "").strip() + tags = request.form.get("tags", "").strip() + billable = request.form.get("billable") == "on" + # Validate required fields if not all([project_id, start_date, start_time, end_date, end_time]): - flash(_('All fields are required'), 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - + flash(_("All fields are required"), "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + # Check if project exists project = Project.query.get(project_id) if not project: - flash(_('Invalid project selected'), 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - + flash(_("Invalid project selected"), "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + # Check if project is active (not archived or inactive) - if project.status == 'archived': - flash(_('Cannot create time entries for an archived project. Please unarchive the project first.'), 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - elif project.status != 'active': - flash(_('Cannot create time entries for an inactive project'), 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - + if project.status == "archived": + flash(_("Cannot create time entries for an archived project. Please unarchive the project first."), "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + elif project.status != "active": + flash(_("Cannot create time entries for an inactive project"), "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + # Validate task if provided if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - flash(_('Invalid task selected'), 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - + flash(_("Invalid task selected"), "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + # Parse datetime with timezone awareness try: start_time_parsed = parse_local_datetime(start_date, start_time) end_time_parsed = parse_local_datetime(end_date, end_time) except ValueError: - flash(_('Invalid date/time format'), 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - + flash(_("Invalid date/time format"), "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + # Validate time range if end_time_parsed <= start_time_parsed: - flash('End time must be after start time', 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - + flash("End time must be after start time", "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + # Create manual entry entry = TimeEntry( user_id=current_user.id, @@ -629,185 +710,251 @@ def manual_entry(): end_time=end_time_parsed, notes=notes, tags=tags, - source='manual', - billable=billable + source="manual", + billable=billable, ) - + db.session.add(entry) - if not safe_commit('manual_entry', {'user_id': current_user.id, 'project_id': project_id, 'task_id': task_id}): - flash(_('Could not create manual entry due to a database error. Please check server logs.'), 'error') - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - + if not safe_commit("manual_entry", {"user_id": current_user.id, "project_id": project_id, "task_id": task_id}): + flash(_("Could not create manual entry due to a database error. Please check server logs."), "error") + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + if task_id: task = Task.query.get(task_id) task_name = task.name if task else "Unknown Task" - flash(f'Manual entry created for {project.name} - {task_name}', 'success') + flash(f"Manual entry created for {project.name} - {task_name}", "success") else: - flash(f'Manual entry created for {project.name}', 'success') - - return redirect(url_for('main.dashboard')) - - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id, template_data=template_data) - -@timer_bp.route('/timer/manual/') + flash(f"Manual entry created for {project.name}", "success") + + return redirect(url_for("main.dashboard")) + + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + template_data=template_data, + ) + + +@timer_bp.route("/timer/manual/") @login_required def manual_entry_for_project(project_id): """Create a manual time entry for a specific project""" - task_id = request.args.get('task_id', type=int) - + task_id = request.args.get("task_id", type=int) + # Check if project exists and is active - project = Project.query.filter_by(id=project_id, status='active').first() + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - flash('Invalid project selected', 'error') - return redirect(url_for('main.dashboard')) - + flash("Invalid project selected", "error") + return redirect(url_for("main.dashboard")) + # Get active projects for dropdown - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - - return render_template('timer/manual_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() -@timer_bp.route('/timer/bulk', methods=['GET', 'POST']) + return render_template( + "timer/manual_entry.html", projects=active_projects, selected_project_id=project_id, selected_task_id=task_id + ) + + +@timer_bp.route("/timer/bulk", methods=["GET", "POST"]) @login_required def bulk_entry(): """Create bulk time entries for multiple days""" # Get active projects for dropdown - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Get project_id and task_id from query parameters for pre-filling - project_id = request.args.get('project_id', type=int) - task_id = request.args.get('task_id', type=int) - - if request.method == 'POST': - project_id = request.form.get('project_id', type=int) - task_id = request.form.get('task_id', type=int) - start_date = request.form.get('start_date') - end_date = request.form.get('end_date') - start_time = request.form.get('start_time') - end_time = request.form.get('end_time') - notes = request.form.get('notes', '').strip() - tags = request.form.get('tags', '').strip() - billable = request.form.get('billable') == 'on' - skip_weekends = request.form.get('skip_weekends') == 'on' - + project_id = request.args.get("project_id", type=int) + task_id = request.args.get("task_id", type=int) + + if request.method == "POST": + project_id = request.form.get("project_id", type=int) + task_id = request.form.get("task_id", type=int) + start_date = request.form.get("start_date") + end_date = request.form.get("end_date") + start_time = request.form.get("start_time") + end_time = request.form.get("end_time") + notes = request.form.get("notes", "").strip() + tags = request.form.get("tags", "").strip() + billable = request.form.get("billable") == "on" + skip_weekends = request.form.get("skip_weekends") == "on" + # Validate required fields if not all([project_id, start_date, end_date, start_time, end_time]): - flash(_('All fields are required'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash(_("All fields are required"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Check if project exists project = Project.query.get(project_id) if not project: - flash(_('Invalid project selected'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash(_("Invalid project selected"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Check if project is active (not archived or inactive) - if project.status == 'archived': - flash(_('Cannot create time entries for an archived project. Please unarchive the project first.'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - elif project.status != 'active': - flash(_('Cannot create time entries for an inactive project'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + if project.status == "archived": + flash(_("Cannot create time entries for an archived project. Please unarchive the project first."), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + elif project.status != "active": + flash(_("Cannot create time entries for an inactive project"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Validate task if provided if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - flash(_('Invalid task selected'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash(_("Invalid task selected"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Parse and validate dates try: from datetime import datetime, timedelta - start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() - end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() - + + start_date_obj = datetime.strptime(start_date, "%Y-%m-%d").date() + end_date_obj = datetime.strptime(end_date, "%Y-%m-%d").date() + if end_date_obj < start_date_obj: - flash(_('End date must be after or equal to start date'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash(_("End date must be after or equal to start date"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Check for reasonable date range (max 31 days) if (end_date_obj - start_date_obj).days > 31: - flash(_('Date range cannot exceed 31 days'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + flash(_("Date range cannot exceed 31 days"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) except ValueError: - flash(_('Invalid date format'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash(_("Invalid date format"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Parse and validate times try: - start_time_obj = datetime.strptime(start_time, '%H:%M').time() - end_time_obj = datetime.strptime(end_time, '%H:%M').time() - + start_time_obj = datetime.strptime(start_time, "%H:%M").time() + end_time_obj = datetime.strptime(end_time, "%H:%M").time() + if end_time_obj <= start_time_obj: - flash('End time must be after start time', 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + flash("End time must be after start time", "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) except ValueError: - flash(_('Invalid time format'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash(_("Invalid time format"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Generate date range current_date = start_date_obj dates_to_create = [] - + while current_date <= end_date_obj: # Skip weekends if requested if skip_weekends and current_date.weekday() >= 5: # Saturday = 5, Sunday = 6 current_date += timedelta(days=1) continue - + dates_to_create.append(current_date) current_date += timedelta(days=1) - + if not dates_to_create: - flash(_('No valid dates found in the selected range'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash(_("No valid dates found in the selected range"), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Check for existing entries on the same dates/times from app.models.time_entry import local_now + existing_entries = [] - + for date_obj in dates_to_create: start_datetime = datetime.combine(date_obj, start_time_obj) end_datetime = datetime.combine(date_obj, end_time_obj) - + # Check for overlapping entries overlapping = TimeEntry.query.filter( TimeEntry.user_id == current_user.id, TimeEntry.start_time <= end_datetime, TimeEntry.end_time >= start_datetime, - TimeEntry.end_time.isnot(None) + TimeEntry.end_time.isnot(None), ).first() - + if overlapping: - existing_entries.append(date_obj.strftime('%Y-%m-%d')) - + existing_entries.append(date_obj.strftime("%Y-%m-%d")) + if existing_entries: - flash(f'Time entries already exist for these dates: {", ".join(existing_entries[:5])}{"..." if len(existing_entries) > 5 else ""}', 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + flash( + f'Time entries already exist for these dates: {", ".join(existing_entries[:5])}{"..." if len(existing_entries) > 5 else ""}', + "error", + ) + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + # Create bulk entries created_entries = [] - + try: for date_obj in dates_to_create: start_datetime = datetime.combine(date_obj, start_time_obj) end_datetime = datetime.combine(date_obj, end_time_obj) - + entry = TimeEntry( user_id=current_user.id, project_id=project_id, @@ -816,192 +963,224 @@ def bulk_entry(): end_time=end_datetime, notes=notes, tags=tags, - source='manual', - billable=billable + source="manual", + billable=billable, ) - + db.session.add(entry) created_entries.append(entry) - - if not safe_commit('bulk_entry', {'user_id': current_user.id, 'project_id': project_id, 'count': len(created_entries)}): - flash(_('Could not create bulk entries due to a database error. Please check server logs.'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - + + if not safe_commit( + "bulk_entry", {"user_id": current_user.id, "project_id": project_id, "count": len(created_entries)} + ): + flash(_("Could not create bulk entries due to a database error. Please check server logs."), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + task_name = "" if task_id: task = Task.query.get(task_id) task_name = f" - {task.name}" if task else "" - - flash(f'Successfully created {len(created_entries)} time entries for {project.name}{task_name}', 'success') - return redirect(url_for('main.dashboard')) - + + flash(f"Successfully created {len(created_entries)} time entries for {project.name}{task_name}", "success") + return redirect(url_for("main.dashboard")) + except Exception as e: db.session.rollback() current_app.logger.exception("Error creating bulk entries: %s", e) - flash(_('An error occurred while creating bulk entries. Please try again.'), 'error') - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) - -@timer_bp.route('/timer') + flash(_("An error occurred while creating bulk entries. Please try again."), "error") + return render_template( + "timer/bulk_entry.html", + projects=active_projects, + selected_project_id=project_id, + selected_task_id=task_id, + ) + + return render_template( + "timer/bulk_entry.html", projects=active_projects, selected_project_id=project_id, selected_task_id=task_id + ) + + +@timer_bp.route("/timer") @login_required def timer_page(): """Dedicated timer page with visual progress ring and quick project selection""" active_timer = current_user.active_timer - + # Get active projects for dropdown - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Get recent projects (projects used in last 30 days) thirty_days_ago = datetime.utcnow() - timedelta(days=30) - recent_project_ids = db.session.query( - TimeEntry.project_id - ).filter( - TimeEntry.user_id == current_user.id, - TimeEntry.start_time >= thirty_days_ago, - TimeEntry.end_time.isnot(None) - ).group_by( - TimeEntry.project_id - ).order_by( - db.func.max(TimeEntry.start_time).desc() - ).limit(5).all() - + recent_project_ids = ( + db.session.query(TimeEntry.project_id) + .filter( + TimeEntry.user_id == current_user.id, + TimeEntry.start_time >= thirty_days_ago, + TimeEntry.end_time.isnot(None), + ) + .group_by(TimeEntry.project_id) + .order_by(db.func.max(TimeEntry.start_time).desc()) + .limit(5) + .all() + ) + recent_project_ids_list = [pid[0] for pid in recent_project_ids] if recent_project_ids_list: # Create a dict to preserve order from recent_project_ids_list order_map = {pid: idx for idx, pid in enumerate(recent_project_ids_list)} recent_projects = Project.query.filter( - Project.id.in_(recent_project_ids_list), - Project.status == 'active' + Project.id.in_(recent_project_ids_list), Project.status == "active" ).all() # Sort by order in recent_project_ids_list recent_projects.sort(key=lambda p: order_map.get(p.id, 999)) else: recent_projects = [] - + # Get tasks for active timer's project if timer is active tasks = [] if active_timer and active_timer.project_id: - tasks = Task.query.filter( - Task.project_id == active_timer.project_id, - Task.status.in_(['todo', 'in_progress', 'review']) - ).order_by(Task.name).all() - + tasks = ( + Task.query.filter( + Task.project_id == active_timer.project_id, 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() - + + 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', + "timer/timer_page.html", active_timer=active_timer, projects=active_projects, recent_projects=recent_projects, tasks=tasks, - templates=templates + templates=templates, ) -@timer_bp.route('/timer/calendar') + +@timer_bp.route("/timer/calendar") @login_required def calendar_view(): """Calendar UI combining day/week/month with list toggle.""" # Provide projects for quick assignment during drag-create - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - return render_template('timer/calendar.html', projects=active_projects) + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + return render_template("timer/calendar.html", projects=active_projects) -@timer_bp.route('/timer/bulk/') + +@timer_bp.route("/timer/bulk/") @login_required def bulk_entry_for_project(project_id): """Create bulk time entries for a specific project""" - task_id = request.args.get('task_id', type=int) - + task_id = request.args.get("task_id", type=int) + # Check if project exists and is active - project = Project.query.filter_by(id=project_id, status='active').first() + project = Project.query.filter_by(id=project_id, status="active").first() if not project: - flash('Invalid project selected', 'error') - return redirect(url_for('main.dashboard')) - + flash("Invalid project selected", "error") + return redirect(url_for("main.dashboard")) + # Get active projects for dropdown - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - - return render_template('timer/bulk_entry.html', projects=active_projects, - selected_project_id=project_id, selected_task_id=task_id) + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + + return render_template( + "timer/bulk_entry.html", projects=active_projects, selected_project_id=project_id, selected_task_id=task_id + ) + -@timer_bp.route('/timer/duplicate/') +@timer_bp.route("/timer/duplicate/") @login_required def duplicate_timer(timer_id): """Duplicate an existing time entry - opens manual entry form with pre-filled data""" timer = TimeEntry.query.get_or_404(timer_id) - + # Check if user can duplicate this timer if timer.user_id != current_user.id and not current_user.is_admin: - flash(_('You can only duplicate your own timers'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("You can only duplicate your own timers"), "error") + return redirect(url_for("main.dashboard")) + # Get active projects for dropdown - active_projects = Project.query.filter_by(status='active').order_by(Project.name).all() - + active_projects = Project.query.filter_by(status="active").order_by(Project.name).all() + # Track duplication event - log_event("timer.duplicated", - user_id=current_user.id, - time_entry_id=timer.id, - project_id=timer.project_id, - task_id=timer.task_id) - track_event(current_user.id, "timer.duplicated", { - "time_entry_id": timer.id, - "project_id": timer.project_id, - "task_id": timer.task_id, - "has_notes": bool(timer.notes), - "has_tags": bool(timer.tags) - }) - + log_event( + "timer.duplicated", + user_id=current_user.id, + time_entry_id=timer.id, + project_id=timer.project_id, + task_id=timer.task_id, + ) + track_event( + current_user.id, + "timer.duplicated", + { + "time_entry_id": timer.id, + "project_id": timer.project_id, + "task_id": timer.task_id, + "has_notes": bool(timer.notes), + "has_tags": bool(timer.tags), + }, + ) + # Render the manual entry form with pre-filled data - return render_template('timer/manual_entry.html', - projects=active_projects, - selected_project_id=timer.project_id, - selected_task_id=timer.task_id, - prefill_notes=timer.notes, - prefill_tags=timer.tags, - prefill_billable=timer.billable, - is_duplicate=True, - original_entry=timer) - -@timer_bp.route('/timer/resume/') + return render_template( + "timer/manual_entry.html", + projects=active_projects, + selected_project_id=timer.project_id, + selected_task_id=timer.task_id, + prefill_notes=timer.notes, + prefill_tags=timer.tags, + prefill_billable=timer.billable, + is_duplicate=True, + original_entry=timer, + ) + + +@timer_bp.route("/timer/resume/") @login_required def resume_timer(timer_id): """Resume an existing time entry - starts a new active timer with same properties""" timer = TimeEntry.query.get_or_404(timer_id) - + # Check if user can resume this timer if timer.user_id != current_user.id and not current_user.is_admin: - flash(_('You can only resume your own timers'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("You can only resume your own timers"), "error") + return redirect(url_for("main.dashboard")) + # Check if user already has an active timer active_timer = current_user.active_timer if active_timer: - flash('You already have an active timer. Stop it before resuming another one.', 'error') + flash("You already have an active timer. Stop it before resuming another one.", "error") current_app.logger.info("Resume timer blocked: user already has an active timer") - return redirect(url_for('main.dashboard')) - + return redirect(url_for("main.dashboard")) + # Check if project is still active project = Project.query.get(timer.project_id) if not project: - flash(_('Project no longer exists'), 'error') - return redirect(url_for('main.dashboard')) - - if project.status == 'archived': - flash(_('Cannot start timer for an archived project. Please unarchive the project first.'), 'error') - return redirect(url_for('main.dashboard')) - elif project.status != 'active': - flash(_('Cannot start timer for an inactive project'), 'error') - return redirect(url_for('main.dashboard')) - + flash(_("Project no longer exists"), "error") + return redirect(url_for("main.dashboard")) + + if project.status == "archived": + flash(_("Cannot start timer for an archived project. Please unarchive the project first."), "error") + return redirect(url_for("main.dashboard")) + elif project.status != "active": + flash(_("Cannot start timer for an inactive project"), "error") + return redirect(url_for("main.dashboard")) + # Validate task if it exists if timer.task_id: task = Task.query.filter_by(id=timer.task_id, project_id=timer.project_id).first() @@ -1012,9 +1191,10 @@ def resume_timer(timer_id): task_id = timer.task_id else: task_id = None - + # Create new timer with copied properties from app.models.time_entry import local_now + new_timer = TimeEntry( user_id=current_user.id, project_id=timer.project_id, @@ -1022,69 +1202,82 @@ def resume_timer(timer_id): start_time=local_now(), notes=timer.notes, tags=timer.tags, - source='auto', - billable=timer.billable + source="auto", + billable=timer.billable, ) - + db.session.add(new_timer) - if not safe_commit('resume_timer', {'user_id': current_user.id, 'original_timer_id': timer_id, 'project_id': timer.project_id}): - flash(_('Could not resume timer due to a database error. Please check server logs.'), 'error') - return redirect(url_for('main.dashboard')) - - current_app.logger.info("Resumed timer id=%s from original timer=%s for user=%s project_id=%s", - new_timer.id, timer_id, current_user.username, timer.project_id) - + if not safe_commit( + "resume_timer", {"user_id": current_user.id, "original_timer_id": timer_id, "project_id": timer.project_id} + ): + flash(_("Could not resume timer due to a database error. Please check server logs."), "error") + return redirect(url_for("main.dashboard")) + + current_app.logger.info( + "Resumed timer id=%s from original timer=%s for user=%s project_id=%s", + new_timer.id, + timer_id, + current_user.username, + timer.project_id, + ) + # Track timer resumed event - log_event("timer.resumed", - user_id=current_user.id, - time_entry_id=new_timer.id, - original_timer_id=timer_id, - project_id=timer.project_id, - task_id=task_id, - description=timer.notes) - track_event(current_user.id, "timer.resumed", { - "time_entry_id": new_timer.id, - "original_timer_id": timer_id, - "project_id": timer.project_id, - "task_id": task_id, - "has_notes": bool(timer.notes), - "has_tags": bool(timer.tags) - }) - + log_event( + "timer.resumed", + user_id=current_user.id, + time_entry_id=new_timer.id, + original_timer_id=timer_id, + project_id=timer.project_id, + task_id=task_id, + description=timer.notes, + ) + track_event( + current_user.id, + "timer.resumed", + { + "time_entry_id": new_timer.id, + "original_timer_id": timer_id, + "project_id": timer.project_id, + "task_id": task_id, + "has_notes": bool(timer.notes), + "has_tags": bool(timer.tags), + }, + ) + # Log activity project_name = project.name task = Task.query.get(task_id) if task_id else None task_name = task.name if task else None Activity.log( user_id=current_user.id, - action='started', - entity_type='time_entry', + action="started", + entity_type="time_entry", entity_id=new_timer.id, - entity_name=f'{project_name}' + (f' - {task_name}' if task_name else ''), - description=f'Resumed timer for {project_name}' + (f' - {task_name}' if task_name else ''), - extra_data={'project_id': timer.project_id, 'task_id': task_id, 'resumed_from': timer_id}, + entity_name=f"{project_name}" + (f" - {task_name}" if task_name else ""), + description=f"Resumed timer for {project_name}" + (f" - {task_name}" if task_name else ""), + extra_data={"project_id": timer.project_id, "task_id": task_id, "resumed_from": timer_id}, ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Emit WebSocket event for real-time updates try: payload = { - 'user_id': current_user.id, - 'timer_id': new_timer.id, - 'project_name': project_name, - 'start_time': new_timer.start_time.isoformat() + "user_id": current_user.id, + "timer_id": new_timer.id, + "project_name": project_name, + "start_time": new_timer.start_time.isoformat(), } if task_id: - payload['task_id'] = task_id - payload['task_name'] = task_name - socketio.emit('timer_started', payload) + payload["task_id"] = task_id + payload["task_name"] = task_name + socketio.emit("timer_started", payload) except Exception as e: current_app.logger.warning("Socket emit failed for timer_resumed: %s", e) - + if task_name: - flash(f'Timer resumed for {project_name} - {task_name}', 'success') + flash(f"Timer resumed for {project_name} - {task_name}", "success") else: - flash(f'Timer resumed for {project_name}', 'success') - - return redirect(url_for('main.dashboard')) + flash(f"Timer resumed for {project_name}", "success") + + return redirect(url_for("main.dashboard")) diff --git a/app/routes/timer_refactored.py b/app/routes/timer_refactored.py index 8b00f2f7..65791f5b 100644 --- a/app/routes/timer_refactored.py +++ b/app/routes/timer_refactored.py @@ -18,230 +18,210 @@ from app.constants import WebhookEvent from app.utils.posthog_funnels import track_onboarding_first_timer -timer_bp = Blueprint('timer', __name__) +timer_bp = Blueprint("timer", __name__) -@timer_bp.route('/timer/start', methods=['POST']) +@timer_bp.route("/timer/start", methods=["POST"]) @login_required def start_timer(): """Start a new timer for the current user - REFACTORED VERSION""" - project_id = request.form.get('project_id', type=int) - task_id = request.form.get('task_id', type=int) - notes = request.form.get('notes', '').strip() - template_id = request.form.get('template_id', type=int) - + project_id = request.form.get("project_id", type=int) + task_id = request.form.get("task_id", type=int) + notes = request.form.get("notes", "").strip() + template_id = request.form.get("template_id", type=int) + current_app.logger.info( "POST /timer/start user=%s project_id=%s task_id=%s template_id=%s", - current_user.username, project_id, task_id, template_id + current_user.username, + project_id, + task_id, + template_id, ) - + # Use service layer service = TimeTrackingService() result = service.start_timer( - user_id=current_user.id, - project_id=project_id, - task_id=task_id, - notes=notes, - template_id=template_id + user_id=current_user.id, project_id=project_id, task_id=task_id, notes=notes, template_id=template_id ) - - if not result['success']: - flash(_(result['message']), 'error') - current_app.logger.warning( - "Start timer failed: %s", result.get('error', 'unknown') - ) - return redirect(url_for('main.dashboard')) - - timer = result['timer'] - + + if not result["success"]: + flash(_(result["message"]), "error") + current_app.logger.warning("Start timer failed: %s", result.get("error", "unknown")) + return redirect(url_for("main.dashboard")) + + timer = result["timer"] + # Log activity project = Project.query.get(project_id) task = Task.query.get(task_id) if task_id else None - + Activity.log( user_id=current_user.id, - action='started', - entity_type='time_entry', + action="started", + entity_type="time_entry", entity_id=timer.id, - entity_name=f'{project.name}' + (f' - {task.name}' if task else ''), - description=f'Started timer for {project.name}' + (f' - {task.name}' if task else ''), - extra_data={'project_id': project_id, 'task_id': task_id}, + entity_name=f"{project.name}" + (f" - {task.name}" if task else ""), + description=f"Started timer for {project.name}" + (f" - {task.name}" if task else ""), + extra_data={"project_id": project_id, "task_id": task_id}, ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track events log_event("timer.started", user_id=current_user.id, project_id=project_id, task_id=task_id) - track_event(current_user.id, "timer.started", { - "project_id": project_id, - "task_id": task_id, - "has_description": bool(notes) - }) - + track_event( + current_user.id, "timer.started", {"project_id": project_id, "task_id": task_id, "has_description": bool(notes)} + ) + # Emit domain event - emit_event(WebhookEvent.TIME_ENTRY_CREATED.value, { - 'entry_id': timer.id, - 'user_id': current_user.id, - 'project_id': project_id - }) - + emit_event( + WebhookEvent.TIME_ENTRY_CREATED.value, + {"entry_id": timer.id, "user_id": current_user.id, "project_id": project_id}, + ) + # Check if first timer (onboarding) time_entry_repo = TimeEntryRepository() - timer_count = len(time_entry_repo.find_by(user_id=current_user.id, source='auto')) + timer_count = len(time_entry_repo.find_by(user_id=current_user.id, source="auto")) if timer_count == 1: - track_onboarding_first_timer(current_user.id, { - "project_id": project_id, - "has_task": bool(task_id), - "has_notes": bool(notes) - }) - + track_onboarding_first_timer( + current_user.id, {"project_id": project_id, "has_task": bool(task_id), "has_notes": bool(notes)} + ) + # Emit WebSocket event try: payload = { - 'user_id': current_user.id, - 'timer_id': timer.id, - 'project_name': project.name, - 'start_time': timer.start_time.isoformat() + "user_id": current_user.id, + "timer_id": timer.id, + "project_name": project.name, + "start_time": timer.start_time.isoformat(), } if task: - payload['task_id'] = task.id - payload['task_name'] = task.name - socketio.emit('timer_started', payload) + payload["task_id"] = task.id + payload["task_name"] = task.name + socketio.emit("timer_started", payload) except Exception as e: current_app.logger.warning("Socket emit failed for timer_started: %s", e) - + if task: - flash(f'Timer started for {project.name} - {task.name}', 'success') + flash(f"Timer started for {project.name} - {task.name}", "success") else: - flash(f'Timer started for {project.name}', 'success') - - return redirect(url_for('main.dashboard')) + flash(f"Timer started for {project.name}", "success") + + return redirect(url_for("main.dashboard")) -@timer_bp.route('/timer/stop', methods=['POST']) +@timer_bp.route("/timer/stop", methods=["POST"]) @login_required def stop_timer(): """Stop the active timer - REFACTORED VERSION""" - entry_id = request.form.get('entry_id', type=int) - + entry_id = request.form.get("entry_id", type=int) + # Use service layer service = TimeTrackingService() - result = service.stop_timer( - user_id=current_user.id, - entry_id=entry_id - ) - - if not result['success']: - flash(_(result['message']), 'error') - return redirect(url_for('main.dashboard')) - - entry = result['entry'] - + result = service.stop_timer(user_id=current_user.id, entry_id=entry_id) + + if not result["success"]: + flash(_(result["message"]), "error") + return redirect(url_for("main.dashboard")) + + entry = result["entry"] + # Log activity Activity.log( user_id=current_user.id, - action='stopped', - entity_type='time_entry', + action="stopped", + entity_type="time_entry", entity_id=entry.id, entity_name=f'{entry.project.name if entry.project else "Unknown"}', - description=f'Stopped timer', - extra_data={'project_id': entry.project_id}, + description=f"Stopped timer", + extra_data={"project_id": entry.project_id}, ip_address=request.remote_addr, - user_agent=request.headers.get('User-Agent') + user_agent=request.headers.get("User-Agent"), ) - + # Track events log_event("timer.stopped", user_id=current_user.id, entry_id=entry.id) - track_event(current_user.id, "timer.stopped", { - "entry_id": entry.id, - "duration_seconds": entry.duration_seconds - }) - + track_event(current_user.id, "timer.stopped", {"entry_id": entry.id, "duration_seconds": entry.duration_seconds}) + # Emit domain event - emit_event(WebhookEvent.TIME_ENTRY_UPDATED.value, { - 'entry_id': entry.id, - 'user_id': current_user.id, - 'project_id': entry.project_id - }) - + emit_event( + WebhookEvent.TIME_ENTRY_UPDATED.value, + {"entry_id": entry.id, "user_id": current_user.id, "project_id": entry.project_id}, + ) + # Emit WebSocket event try: - socketio.emit('timer_stopped', { - 'user_id': current_user.id, - 'entry_id': entry.id, - 'duration_seconds': entry.duration_seconds - }) + socketio.emit( + "timer_stopped", + {"user_id": current_user.id, "entry_id": entry.id, "duration_seconds": entry.duration_seconds}, + ) except Exception as e: current_app.logger.warning("Socket emit failed for timer_stopped: %s", e) - - flash(_('Timer stopped successfully'), 'success') - return redirect(url_for('main.dashboard')) + flash(_("Timer stopped successfully"), "success") + return redirect(url_for("main.dashboard")) -@timer_bp.route('/api/timer/status', methods=['GET']) + +@timer_bp.route("/api/timer/status", methods=["GET"]) @login_required def api_timer_status(): """Get timer status - REFACTORED VERSION""" service = TimeTrackingService() timer = service.get_active_timer(current_user.id) - + if timer: - return success_response(data={ - 'active': True, - 'timer': { - 'id': timer.id, - 'project_id': timer.project_id, - 'project_name': timer.project.name if timer.project else None, - 'task_id': timer.task_id, - 'task_name': timer.task.name if timer.task else None, - 'start_time': timer.start_time.isoformat(), - 'notes': timer.notes + return success_response( + data={ + "active": True, + "timer": { + "id": timer.id, + "project_id": timer.project_id, + "project_name": timer.project.name if timer.project else None, + "task_id": timer.task_id, + "task_name": timer.task.name if timer.task else None, + "start_time": timer.start_time.isoformat(), + "notes": timer.notes, + }, } - }) + ) else: - return success_response(data={'active': False}) + return success_response(data={"active": False}) -@timer_bp.route('/api/timer/start', methods=['POST']) +@timer_bp.route("/api/timer/start", methods=["POST"]) @login_required def api_start_timer(): """Start timer via API - REFACTORED VERSION""" from app.utils.validation import validate_json_request from app.schemas import TimerStartSchema - + try: data = validate_json_request() schema = TimerStartSchema() validated_data = schema.load(data) except Exception as e: - return error_response(str(e), error_code='validation_error', status_code=400) - + return error_response(str(e), error_code="validation_error", status_code=400) + service = TimeTrackingService() result = service.start_timer( user_id=current_user.id, - project_id=validated_data['project_id'], - task_id=validated_data.get('task_id'), - notes=validated_data.get('notes'), - template_id=validated_data.get('template_id') + project_id=validated_data["project_id"], + task_id=validated_data.get("task_id"), + notes=validated_data.get("notes"), + template_id=validated_data.get("template_id"), ) - - if result['success']: + + if result["success"]: # Emit domain event - emit_event(WebhookEvent.TIME_ENTRY_CREATED.value, { - 'entry_id': result['timer'].id, - 'user_id': current_user.id, - 'project_id': validated_data['project_id'] - }) - + emit_event( + WebhookEvent.TIME_ENTRY_CREATED.value, + {"entry_id": result["timer"].id, "user_id": current_user.id, "project_id": validated_data["project_id"]}, + ) + return success_response( - data=result['timer'].to_dict() if hasattr(result['timer'], 'to_dict') else result['timer'], - message=result['message'], - status_code=201 + data=result["timer"].to_dict() if hasattr(result["timer"], "to_dict") else result["timer"], + message=result["message"], + status_code=201, ) else: - return error_response( - message=result['message'], - error_code=result.get('error', 'error'), - status_code=400 - ) - + return error_response(message=result["message"], error_code=result.get("error", "error"), status_code=400) diff --git a/app/routes/user.py b/app/routes/user.py index f39628f2..1ac19564 100644 --- a/app/routes/user.py +++ b/app/routes/user.py @@ -9,10 +9,10 @@ import pytz from app.utils.timezone import get_available_timezones -user_bp = Blueprint('user', __name__) +user_bp = Blueprint("user", __name__) -@user_bp.route('/profile') +@user_bp.route("/profile") @login_required def profile(): """User profile page""" @@ -20,50 +20,52 @@ def profile(): total_hours = current_user.total_hours active_timer = current_user.active_timer recent_entries = current_user.get_recent_entries(limit=10) - + # Get recent activities recent_activities = Activity.get_recent(user_id=current_user.id, limit=20) - - return render_template('user/profile.html', - user=current_user, - total_hours=total_hours, - active_timer=active_timer, - recent_entries=recent_entries, - recent_activities=recent_activities) + + return render_template( + "user/profile.html", + user=current_user, + total_hours=total_hours, + active_timer=active_timer, + recent_entries=recent_entries, + recent_activities=recent_activities, + ) -@user_bp.route('/settings', methods=['GET', 'POST']) +@user_bp.route("/settings", methods=["GET", "POST"]) @login_required def settings(): """User settings and preferences page""" - if request.method == 'POST': + if request.method == "POST": try: # Notification preferences - current_user.email_notifications = 'email_notifications' in request.form - current_user.notification_overdue_invoices = 'notification_overdue_invoices' in request.form - current_user.notification_task_assigned = 'notification_task_assigned' in request.form - current_user.notification_task_comments = 'notification_task_comments' in request.form - current_user.notification_weekly_summary = 'notification_weekly_summary' in request.form - + current_user.email_notifications = "email_notifications" in request.form + current_user.notification_overdue_invoices = "notification_overdue_invoices" in request.form + current_user.notification_task_assigned = "notification_task_assigned" in request.form + current_user.notification_task_comments = "notification_task_comments" in request.form + current_user.notification_weekly_summary = "notification_weekly_summary" in request.form + # Profile information - full_name = request.form.get('full_name', '').strip() + full_name = request.form.get("full_name", "").strip() if full_name: current_user.full_name = full_name - - email = request.form.get('email', '').strip() + + email = request.form.get("email", "").strip() if email: current_user.email = email - + # Display preferences - theme_preference = request.form.get('theme_preference') - if theme_preference in ['light', 'dark', None, '']: + theme_preference = request.form.get("theme_preference") + if theme_preference in ["light", "dark", None, ""]: current_user.theme_preference = theme_preference if theme_preference else None - + # Regional settings - timezone = request.form.get('timezone') + timezone = request.form.get("timezone") if timezone is not None: timezone = timezone.strip() - if timezone == '': + if timezone == "": current_user.timezone = None else: try: @@ -71,214 +73,203 @@ def settings(): pytz.timezone(timezone) current_user.timezone = timezone except pytz.exceptions.UnknownTimeZoneError: - flash(_('Invalid timezone selected'), 'error') - return redirect(url_for('user.settings')) - - date_format = request.form.get('date_format') + flash(_("Invalid timezone selected"), "error") + return redirect(url_for("user.settings")) + + date_format = request.form.get("date_format") if date_format: current_user.date_format = date_format - - time_format = request.form.get('time_format') - if time_format in ['12h', '24h']: + + time_format = request.form.get("time_format") + if time_format in ["12h", "24h"]: current_user.time_format = time_format - - week_start_day = request.form.get('week_start_day', type=int) + + week_start_day = request.form.get("week_start_day", type=int) if week_start_day is not None and 0 <= week_start_day <= 6: current_user.week_start_day = week_start_day - + # Language preference - preferred_language = request.form.get('preferred_language') + preferred_language = request.form.get("preferred_language") if preferred_language: current_user.preferred_language = preferred_language - + # Time rounding preferences - current_user.time_rounding_enabled = 'time_rounding_enabled' in request.form - - time_rounding_minutes = request.form.get('time_rounding_minutes', type=int) + current_user.time_rounding_enabled = "time_rounding_enabled" in request.form + + time_rounding_minutes = request.form.get("time_rounding_minutes", type=int) if time_rounding_minutes and time_rounding_minutes in [1, 5, 10, 15, 30, 60]: current_user.time_rounding_minutes = time_rounding_minutes - - time_rounding_method = request.form.get('time_rounding_method') - if time_rounding_method in ['nearest', 'up', 'down']: + + time_rounding_method = request.form.get("time_rounding_method") + if time_rounding_method in ["nearest", "up", "down"]: current_user.time_rounding_method = time_rounding_method - + # Overtime settings - standard_hours_per_day = request.form.get('standard_hours_per_day', type=float) + standard_hours_per_day = request.form.get("standard_hours_per_day", type=float) if standard_hours_per_day is not None: # Validate range (0.5 to 24 hours) if 0.5 <= standard_hours_per_day <= 24: current_user.standard_hours_per_day = standard_hours_per_day else: - flash(_('Standard hours per day must be between 0.5 and 24'), 'error') - return redirect(url_for('user.settings')) - + flash(_("Standard hours per day must be between 0.5 and 24"), "error") + return redirect(url_for("user.settings")) + # Save changes if safe_commit(db.session): # Log activity Activity.log( user_id=current_user.id, - action='updated', - entity_type='user', + action="updated", + entity_type="user", entity_id=current_user.id, entity_name=current_user.username, - description='Updated user settings' + description="Updated user settings", ) - - flash(_('Settings saved successfully'), 'success') + + flash(_("Settings saved successfully"), "success") else: - flash(_('Error saving settings'), 'error') - + flash(_("Error saving settings"), "error") + except Exception as e: - flash(_('Error saving settings: %(error)s', error=str(e)), 'error') + flash(_("Error saving settings: %(error)s", error=str(e)), "error") db.session.rollback() - - return redirect(url_for('user.settings')) - + + return redirect(url_for("user.settings")) + # Get all available timezones timezones = get_available_timezones() - + # Get available languages from config from flask import current_app - languages = current_app.config.get('LANGUAGES', { - 'en': 'English', - 'nl': 'Nederlands', - 'de': 'Deutsch', - 'fr': 'Français', - 'it': 'Italiano', - 'fi': 'Suomi' - }) - + + languages = current_app.config.get( + "LANGUAGES", + {"en": "English", "nl": "Nederlands", "de": "Deutsch", "fr": "Français", "it": "Italiano", "fi": "Suomi"}, + ) + # Get time rounding options from app.utils.time_rounding import get_available_rounding_intervals, get_available_rounding_methods + rounding_intervals = get_available_rounding_intervals() rounding_methods = get_available_rounding_methods() - - return render_template('user/settings.html', - user=current_user, - timezones=timezones, - languages=languages, - rounding_intervals=rounding_intervals, - rounding_methods=rounding_methods) + return render_template( + "user/settings.html", + user=current_user, + timezones=timezones, + languages=languages, + rounding_intervals=rounding_intervals, + rounding_methods=rounding_methods, + ) -@user_bp.route('/api/preferences', methods=['PATCH']) + +@user_bp.route("/api/preferences", methods=["PATCH"]) @login_required def update_preferences(): """API endpoint to update user preferences (for AJAX calls)""" try: data = request.get_json() - - if 'theme_preference' in data: - theme = data['theme_preference'] - if theme in ['light', 'dark', 'system', None, '']: - current_user.theme_preference = theme if theme and theme != 'system' else None - - if 'email_notifications' in data: - current_user.email_notifications = bool(data['email_notifications']) - - if 'timezone' in data: - tz_value = data['timezone'] - if tz_value in [None, '', 'system']: + + if "theme_preference" in data: + theme = data["theme_preference"] + if theme in ["light", "dark", "system", None, ""]: + current_user.theme_preference = theme if theme and theme != "system" else None + + if "email_notifications" in data: + current_user.email_notifications = bool(data["email_notifications"]) + + if "timezone" in data: + tz_value = data["timezone"] + if tz_value in [None, "", "system"]: current_user.timezone = None else: try: pytz.timezone(tz_value) current_user.timezone = tz_value except pytz.exceptions.UnknownTimeZoneError: - return jsonify({'error': 'Invalid timezone'}), 400 - + return jsonify({"error": "Invalid timezone"}), 400 + db.session.commit() - - return jsonify({ - 'success': True, - 'message': _('Preferences updated') - }) - + + return jsonify({"success": True, "message": _("Preferences updated")}) + except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@user_bp.route('/api/theme', methods=['POST']) +@user_bp.route("/api/theme", methods=["POST"]) @login_required def set_theme(): """Quick API endpoint to set theme (for theme switcher)""" try: data = request.get_json() - theme = data.get('theme') - - if theme in ['light', 'dark', None, '']: + theme = data.get("theme") + + if theme in ["light", "dark", None, ""]: current_user.theme_preference = theme if theme else None db.session.commit() - - return jsonify({ - 'success': True, - 'theme': current_user.theme_preference or 'system' - }) - - return jsonify({'error': 'Invalid theme'}), 400 - + + return jsonify({"success": True, "theme": current_user.theme_preference or "system"}) + + return jsonify({"error": "Invalid theme"}), 400 + except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@user_bp.route('/api/language', methods=['POST']) +@user_bp.route("/api/language", methods=["POST"]) @login_required def set_language(): """Quick API endpoint to set language (for language switcher)""" from flask import current_app, session - + try: data = request.get_json() - language = data.get('language') - + language = data.get("language") + # Get available languages from config - available_languages = current_app.config.get('LANGUAGES', {}) - + available_languages = current_app.config.get("LANGUAGES", {}) + if language in available_languages: # Update user preference current_user.preferred_language = language db.session.commit() - + # Also set in session for immediate effect - session['preferred_language'] = language - - return jsonify({ - 'success': True, - 'language': language, - 'message': _('Language updated successfully') - }) - - return jsonify({'error': _('Invalid language')}), 400 - + session["preferred_language"] = language + + return jsonify({"success": True, "language": language, "message": _("Language updated successfully")}) + + return jsonify({"error": _("Invalid language")}), 400 + except Exception as e: db.session.rollback() - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@user_bp.route('/set-language/') +@user_bp.route("/set-language/") def set_language_direct(language): """Direct route to set language (for non-JS fallback)""" from flask import current_app, session - + # Get available languages from config - available_languages = current_app.config.get('LANGUAGES', {}) - + available_languages = current_app.config.get("LANGUAGES", {}) + if language in available_languages: # Set in session for immediate effect - session['preferred_language'] = language - + session["preferred_language"] = language + # If user is logged in, update their preference if current_user.is_authenticated: current_user.preferred_language = language db.session.commit() - flash(_('Language updated to %(language)s', language=available_languages[language]), 'success') - + flash(_("Language updated to %(language)s", language=available_languages[language]), "success") + # Redirect back to referring page or dashboard - next_page = request.referrer or url_for('main.dashboard') + next_page = request.referrer or url_for("main.dashboard") return redirect(next_page) - - flash(_('Invalid language'), 'error') - return redirect(url_for('main.dashboard')) + flash(_("Invalid language"), "error") + return redirect(url_for("main.dashboard")) diff --git a/app/routes/webhooks.py b/app/routes/webhooks.py index 239efbe7..16003a68 100644 --- a/app/routes/webhooks.py +++ b/app/routes/webhooks.py @@ -1,4 +1,5 @@ """Routes for webhook management""" + from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify from flask_babel import gettext as _ from flask_login import login_required, current_user @@ -9,10 +10,10 @@ from app.utils.webhook_service import WebhookService from sqlalchemy.exc import IntegrityError -webhooks_bp = Blueprint('webhooks', __name__) +webhooks_bp = Blueprint("webhooks", __name__) -@webhooks_bp.route('/admin/webhooks') +@webhooks_bp.route("/admin/webhooks") @login_required @admin_required def list_webhooks(): @@ -22,203 +23,195 @@ def list_webhooks(): webhooks = Webhook.query.order_by(Webhook.created_at.desc()).all() else: webhooks = Webhook.query.filter_by(user_id=current_user.id).order_by(Webhook.created_at.desc()).all() - - return render_template('admin/webhooks/list.html', webhooks=webhooks) + + return render_template("admin/webhooks/list.html", webhooks=webhooks) -@webhooks_bp.route('/admin/webhooks/create', methods=['GET', 'POST']) +@webhooks_bp.route("/admin/webhooks/create", methods=["GET", "POST"]) @login_required @admin_required def create_webhook(): """Create a new webhook""" - if request.method == 'POST': + if request.method == "POST": data = request.form - + # Validate required fields - if not data.get('name'): - flash(_('Webhook name is required'), 'error') - return render_template('admin/webhooks/form.html', - webhook=None, - available_events=WebhookService.get_available_events()) - - if not data.get('url'): - flash(_('Webhook URL is required'), 'error') - return render_template('admin/webhooks/form.html', - webhook=None, - available_events=WebhookService.get_available_events()) - + if not data.get("name"): + flash(_("Webhook name is required"), "error") + return render_template( + "admin/webhooks/form.html", webhook=None, available_events=WebhookService.get_available_events() + ) + + if not data.get("url"): + flash(_("Webhook URL is required"), "error") + return render_template( + "admin/webhooks/form.html", webhook=None, available_events=WebhookService.get_available_events() + ) + # Parse events - events = request.form.getlist('events') + events = request.form.getlist("events") if not events: - flash(_('At least one event must be selected'), 'error') - return render_template('admin/webhooks/form.html', - webhook=None, - available_events=WebhookService.get_available_events()) - + flash(_("At least one event must be selected"), "error") + return render_template( + "admin/webhooks/form.html", webhook=None, available_events=WebhookService.get_available_events() + ) + # Create webhook webhook = Webhook( - name=data['name'], - description=data.get('description'), - url=data['url'], + name=data["name"], + description=data.get("description"), + url=data["url"], events=events, - http_method=data.get('http_method', 'POST'), - content_type=data.get('content_type', 'application/json'), - is_active=data.get('is_active') == 'on', + http_method=data.get("http_method", "POST"), + content_type=data.get("content_type", "application/json"), + is_active=data.get("is_active") == "on", user_id=current_user.id, - max_retries=int(data.get('max_retries', 3)), - retry_delay_seconds=int(data.get('retry_delay_seconds', 60)), - timeout_seconds=int(data.get('timeout_seconds', 30)), + max_retries=int(data.get("max_retries", 3)), + retry_delay_seconds=int(data.get("retry_delay_seconds", 60)), + timeout_seconds=int(data.get("timeout_seconds", 30)), ) - + # Generate secret webhook.set_secret() - + try: db.session.add(webhook) db.session.commit() - flash(_('Webhook created successfully'), 'success') - return redirect(url_for('webhooks.view_webhook', webhook_id=webhook.id)) + flash(_("Webhook created successfully"), "success") + return redirect(url_for("webhooks.view_webhook", webhook_id=webhook.id)) except IntegrityError: db.session.rollback() - flash(_('Error creating webhook'), 'error') + flash(_("Error creating webhook"), "error") except Exception as e: db.session.rollback() - flash(_('Error creating webhook: %(error)s', error=str(e)), 'error') - + flash(_("Error creating webhook: %(error)s", error=str(e)), "error") + available_events = WebhookService.get_available_events() - return render_template('admin/webhooks/form.html', - webhook=None, - available_events=available_events) + return render_template("admin/webhooks/form.html", webhook=None, available_events=available_events) -@webhooks_bp.route('/admin/webhooks/') +@webhooks_bp.route("/admin/webhooks/") @login_required @admin_required def view_webhook(webhook_id): """View webhook details and deliveries""" webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not current_user.is_admin and webhook.user_id != current_user.id: - flash(_('Access denied'), 'error') - return redirect(url_for('webhooks.list_webhooks')) - + flash(_("Access denied"), "error") + return redirect(url_for("webhooks.list_webhooks")) + # Get recent deliveries - deliveries = WebhookDelivery.query.filter_by(webhook_id=webhook_id)\ - .order_by(WebhookDelivery.started_at.desc())\ - .limit(50).all() - - return render_template('admin/webhooks/view.html', - webhook=webhook, - deliveries=deliveries) + deliveries = ( + WebhookDelivery.query.filter_by(webhook_id=webhook_id) + .order_by(WebhookDelivery.started_at.desc()) + .limit(50) + .all() + ) + return render_template("admin/webhooks/view.html", webhook=webhook, deliveries=deliveries) -@webhooks_bp.route('/admin/webhooks//edit', methods=['GET', 'POST']) + +@webhooks_bp.route("/admin/webhooks//edit", methods=["GET", "POST"]) @login_required @admin_required def edit_webhook(webhook_id): """Edit a webhook""" webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not current_user.is_admin and webhook.user_id != current_user.id: - flash(_('Access denied'), 'error') - return redirect(url_for('webhooks.list_webhooks')) - - if request.method == 'POST': + flash(_("Access denied"), "error") + return redirect(url_for("webhooks.list_webhooks")) + + if request.method == "POST": data = request.form - + # Update fields - webhook.name = data.get('name', webhook.name) - webhook.description = data.get('description', webhook.description) - webhook.url = data.get('url', webhook.url) - webhook.events = request.form.getlist('events') or webhook.events - webhook.http_method = data.get('http_method', webhook.http_method) - webhook.content_type = data.get('content_type', webhook.content_type) - webhook.is_active = data.get('is_active') == 'on' - webhook.max_retries = int(data.get('max_retries', webhook.max_retries)) - webhook.retry_delay_seconds = int(data.get('retry_delay_seconds', webhook.retry_delay_seconds)) - webhook.timeout_seconds = int(data.get('timeout_seconds', webhook.timeout_seconds)) - + webhook.name = data.get("name", webhook.name) + webhook.description = data.get("description", webhook.description) + webhook.url = data.get("url", webhook.url) + webhook.events = request.form.getlist("events") or webhook.events + webhook.http_method = data.get("http_method", webhook.http_method) + webhook.content_type = data.get("content_type", webhook.content_type) + webhook.is_active = data.get("is_active") == "on" + webhook.max_retries = int(data.get("max_retries", webhook.max_retries)) + webhook.retry_delay_seconds = int(data.get("retry_delay_seconds", webhook.retry_delay_seconds)) + webhook.timeout_seconds = int(data.get("timeout_seconds", webhook.timeout_seconds)) + # Regenerate secret if requested - if data.get('regenerate_secret') == 'on': + if data.get("regenerate_secret") == "on": webhook.set_secret() - + try: db.session.commit() - flash(_('Webhook updated successfully'), 'success') - return redirect(url_for('webhooks.view_webhook', webhook_id=webhook.id)) + flash(_("Webhook updated successfully"), "success") + return redirect(url_for("webhooks.view_webhook", webhook_id=webhook.id)) except Exception as e: db.session.rollback() - flash(_('Error updating webhook: %(error)s', error=str(e)), 'error') - + flash(_("Error updating webhook: %(error)s", error=str(e)), "error") + available_events = WebhookService.get_available_events() - return render_template('admin/webhooks/form.html', - webhook=webhook, - available_events=available_events) + return render_template("admin/webhooks/form.html", webhook=webhook, available_events=available_events) -@webhooks_bp.route('/admin/webhooks//delete', methods=['POST']) +@webhooks_bp.route("/admin/webhooks//delete", methods=["POST"]) @login_required @admin_required def delete_webhook(webhook_id): """Delete a webhook""" webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not current_user.is_admin and webhook.user_id != current_user.id: - flash(_('Access denied'), 'error') - return redirect(url_for('webhooks.list_webhooks')) - + flash(_("Access denied"), "error") + return redirect(url_for("webhooks.list_webhooks")) + try: db.session.delete(webhook) db.session.commit() - flash(_('Webhook deleted successfully'), 'success') + flash(_("Webhook deleted successfully"), "success") except Exception as e: db.session.rollback() - flash(_('Error deleting webhook: %(error)s', error=str(e)), 'error') - - return redirect(url_for('webhooks.list_webhooks')) + flash(_("Error deleting webhook: %(error)s", error=str(e)), "error") + + return redirect(url_for("webhooks.list_webhooks")) -@webhooks_bp.route('/admin/webhooks//test', methods=['POST']) +@webhooks_bp.route("/admin/webhooks//test", methods=["POST"]) @login_required @admin_required def test_webhook(webhook_id): """Test a webhook by sending a test event""" webhook = Webhook.query.get_or_404(webhook_id) - + # Check permissions if not current_user.is_admin and webhook.user_id != current_user.id: - return jsonify({'error': 'Access denied'}), 403 - + return jsonify({"error": "Access denied"}), 403 + if not webhook.is_active: - return jsonify({'error': 'Webhook is not active'}), 400 - + return jsonify({"error": "Webhook is not active"}), 400 + # Send test event try: test_payload = { - 'event_type': 'webhook.test', - 'timestamp': db.session.execute(db.text('SELECT CURRENT_TIMESTAMP')).scalar().isoformat(), - 'user': { - 'id': current_user.id, - 'username': current_user.username, + "event_type": "webhook.test", + "timestamp": db.session.execute(db.text("SELECT CURRENT_TIMESTAMP")).scalar().isoformat(), + "user": { + "id": current_user.id, + "username": current_user.username, }, - 'message': 'This is a test webhook event', + "message": "This is a test webhook event", } - + delivery = WebhookService.deliver_webhook( webhook=webhook, - event_type='webhook.test', + event_type="webhook.test", payload=test_payload, - event_id=f'test_{webhook_id}_{int(db.session.execute(db.text("SELECT EXTRACT(EPOCH FROM NOW())")).scalar())}' + event_id=f'test_{webhook_id}_{int(db.session.execute(db.text("SELECT EXTRACT(EPOCH FROM NOW())")).scalar())}', ) - - return jsonify({ - 'success': True, - 'delivery': delivery.to_dict(), - 'message': 'Test webhook sent successfully' - }) - except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"success": True, "delivery": delivery.to_dict(), "message": "Test webhook sent successfully"}) + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/app/routes/weekly_goals.py b/app/routes/weekly_goals.py index c3d3ffd2..a4247162 100644 --- a/app/routes/weekly_goals.py +++ b/app/routes/weekly_goals.py @@ -7,153 +7,150 @@ from datetime import datetime, timedelta from sqlalchemy import func -weekly_goals_bp = Blueprint('weekly_goals', __name__) +weekly_goals_bp = Blueprint("weekly_goals", __name__) -@weekly_goals_bp.route('/goals') +@weekly_goals_bp.route("/goals") @login_required def index(): """Display weekly goals overview page""" current_app.logger.info(f"GET /goals user={current_user.username}") - + # Get current week goal current_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) - + # Get all goals for the user, ordered by week - all_goals = WeeklyTimeGoal.query.filter_by( - user_id=current_user.id - ).order_by( - WeeklyTimeGoal.week_start_date.desc() - ).limit(12).all() # Show last 12 weeks - + all_goals = ( + WeeklyTimeGoal.query.filter_by(user_id=current_user.id) + .order_by(WeeklyTimeGoal.week_start_date.desc()) + .limit(12) + .all() + ) # Show last 12 weeks + # Update status for all goals for goal in all_goals: goal.update_status() - + # Calculate statistics stats = { - 'total_goals': len(all_goals), - 'completed': sum(1 for g in all_goals if g.status == 'completed'), - 'failed': sum(1 for g in all_goals if g.status == 'failed'), - 'active': sum(1 for g in all_goals if g.status == 'active'), - 'completion_rate': 0 + "total_goals": len(all_goals), + "completed": sum(1 for g in all_goals if g.status == "completed"), + "failed": sum(1 for g in all_goals if g.status == "failed"), + "active": sum(1 for g in all_goals if g.status == "active"), + "completion_rate": 0, } - - if stats['total_goals'] > 0: - completed_or_failed = stats['completed'] + stats['failed'] + + if stats["total_goals"] > 0: + completed_or_failed = stats["completed"] + stats["failed"] if completed_or_failed > 0: - stats['completion_rate'] = round((stats['completed'] / completed_or_failed) * 100, 1) - + stats["completion_rate"] = round((stats["completed"] / completed_or_failed) * 100, 1) + # Track page view track_event( user_id=current_user.id, - event_name='weekly_goals_viewed', - properties={'has_current_goal': current_goal is not None} - ) - - return render_template( - 'weekly_goals/index.html', - current_goal=current_goal, - goals=all_goals, - stats=stats + event_name="weekly_goals_viewed", + properties={"has_current_goal": current_goal is not None}, ) + return render_template("weekly_goals/index.html", current_goal=current_goal, goals=all_goals, stats=stats) -@weekly_goals_bp.route('/goals/create', methods=['GET', 'POST']) + +@weekly_goals_bp.route("/goals/create", methods=["GET", "POST"]) @login_required def create(): """Create a new weekly time goal""" - if request.method == 'GET': + if request.method == "GET": current_app.logger.info(f"GET /goals/create user={current_user.username}") - return render_template('weekly_goals/create.html') - + return render_template("weekly_goals/create.html") + # POST request current_app.logger.info(f"POST /goals/create user={current_user.username}") - - target_hours = request.form.get('target_hours', type=float) - week_start_date_str = request.form.get('week_start_date') - notes = request.form.get('notes', '').strip() - + + target_hours = request.form.get("target_hours", type=float) + week_start_date_str = request.form.get("week_start_date") + notes = request.form.get("notes", "").strip() + if not target_hours or target_hours <= 0: - flash(_('Please enter a valid target hours (greater than 0)'), 'error') - return redirect(url_for('weekly_goals.create')) - + flash(_("Please enter a valid target hours (greater than 0)"), "error") + return redirect(url_for("weekly_goals.create")) + # Parse week start date week_start_date = None if week_start_date_str: try: - week_start_date = datetime.strptime(week_start_date_str, '%Y-%m-%d').date() + week_start_date = datetime.strptime(week_start_date_str, "%Y-%m-%d").date() except ValueError: - flash(_('Invalid date format'), 'error') - return redirect(url_for('weekly_goals.create')) - + flash(_("Invalid date format"), "error") + return redirect(url_for("weekly_goals.create")) + # Check if goal already exists for this week if week_start_date: existing_goal = WeeklyTimeGoal.query.filter( WeeklyTimeGoal.user_id == current_user.id, WeeklyTimeGoal.week_start_date == week_start_date, - WeeklyTimeGoal.status != 'cancelled' + WeeklyTimeGoal.status != "cancelled", ).first() - + if existing_goal: - flash(_('A goal already exists for this week. Please edit the existing goal instead.'), 'warning') - return redirect(url_for('weekly_goals.edit', goal_id=existing_goal.id)) - + flash(_("A goal already exists for this week. Please edit the existing goal instead."), "warning") + return redirect(url_for("weekly_goals.edit", goal_id=existing_goal.id)) + # Create new goal goal = WeeklyTimeGoal( - user_id=current_user.id, - target_hours=target_hours, - week_start_date=week_start_date, - notes=notes + user_id=current_user.id, target_hours=target_hours, week_start_date=week_start_date, notes=notes ) - + db.session.add(goal) - + if safe_commit(db.session): - flash(_('Weekly time goal created successfully!'), 'success') + flash(_("Weekly time goal created successfully!"), "success") log_event( - 'weekly_goal.created', + "weekly_goal.created", user_id=current_user.id, - resource_type='weekly_goal', + resource_type="weekly_goal", resource_id=goal.id, target_hours=target_hours, - week_label=goal.week_label + week_label=goal.week_label, ) track_event( user_id=current_user.id, - event_name='weekly_goal_created', - properties={'target_hours': target_hours, 'week_label': goal.week_label} + event_name="weekly_goal_created", + properties={"target_hours": target_hours, "week_label": goal.week_label}, ) - return redirect(url_for('weekly_goals.index')) + return redirect(url_for("weekly_goals.index")) else: - flash(_('Failed to create goal. Please try again.'), 'error') - return redirect(url_for('weekly_goals.create')) + flash(_("Failed to create goal. Please try again."), "error") + return redirect(url_for("weekly_goals.create")) -@weekly_goals_bp.route('/goals/') +@weekly_goals_bp.route("/goals/") @login_required def view(goal_id): """View details of a specific weekly goal""" current_app.logger.info(f"GET /goals/{goal_id} user={current_user.username}") - + goal = WeeklyTimeGoal.query.get_or_404(goal_id) - + # Ensure user can only view their own goals if goal.user_id != current_user.id: - flash(_('You do not have permission to view this goal'), 'error') - return redirect(url_for('weekly_goals.index')) - + flash(_("You do not have permission to view this goal"), "error") + return redirect(url_for("weekly_goals.index")) + # Update goal status goal.update_status() - + # Get time entries for this week - time_entries = TimeEntry.query.filter( - TimeEntry.user_id == current_user.id, - TimeEntry.end_time.isnot(None), - func.date(TimeEntry.start_time) >= goal.week_start_date, - func.date(TimeEntry.start_time) <= goal.week_end_date - ).order_by(TimeEntry.start_time.desc()).all() - + time_entries = ( + TimeEntry.query.filter( + TimeEntry.user_id == current_user.id, + TimeEntry.end_time.isnot(None), + func.date(TimeEntry.start_time) >= goal.week_start_date, + func.date(TimeEntry.start_time) <= goal.week_end_date, + ) + .order_by(TimeEntry.start_time.desc()) + .all() + ) + # Calculate daily breakdown daily_hours = {} for entry in time_entries: @@ -161,239 +158,228 @@ def view(goal_id): if entry_date not in daily_hours: daily_hours[entry_date] = 0 daily_hours[entry_date] += entry.duration_seconds / 3600 - + # Fill in missing days with 0 current_date = goal.week_start_date while current_date <= goal.week_end_date: if current_date not in daily_hours: daily_hours[current_date] = 0 current_date += timedelta(days=1) - + # Sort by date daily_hours = dict(sorted(daily_hours.items())) - + track_event( user_id=current_user.id, - event_name='weekly_goal_viewed', - properties={'goal_id': goal_id, 'week_label': goal.week_label} - ) - - return render_template( - 'weekly_goals/view.html', - goal=goal, - time_entries=time_entries, - daily_hours=daily_hours + event_name="weekly_goal_viewed", + properties={"goal_id": goal_id, "week_label": goal.week_label}, ) + return render_template("weekly_goals/view.html", goal=goal, time_entries=time_entries, daily_hours=daily_hours) + -@weekly_goals_bp.route('/goals//edit', methods=['GET', 'POST']) +@weekly_goals_bp.route("/goals//edit", methods=["GET", "POST"]) @login_required def edit(goal_id): """Edit a weekly time goal""" goal = WeeklyTimeGoal.query.get_or_404(goal_id) - + # Ensure user can only edit their own goals if goal.user_id != current_user.id: - flash(_('You do not have permission to edit this goal'), 'error') - return redirect(url_for('weekly_goals.index')) - - if request.method == 'GET': + flash(_("You do not have permission to edit this goal"), "error") + return redirect(url_for("weekly_goals.index")) + + if request.method == "GET": current_app.logger.info(f"GET /goals/{goal_id}/edit user={current_user.username}") - return render_template('weekly_goals/edit.html', goal=goal) - + return render_template("weekly_goals/edit.html", goal=goal) + # POST request current_app.logger.info(f"POST /goals/{goal_id}/edit user={current_user.username}") - - target_hours = request.form.get('target_hours', type=float) - notes = request.form.get('notes', '').strip() - status = request.form.get('status') - + + target_hours = request.form.get("target_hours", type=float) + notes = request.form.get("notes", "").strip() + status = request.form.get("status") + if not target_hours or target_hours <= 0: - flash(_('Please enter a valid target hours (greater than 0)'), 'error') - return redirect(url_for('weekly_goals.edit', goal_id=goal_id)) - + flash(_("Please enter a valid target hours (greater than 0)"), "error") + return redirect(url_for("weekly_goals.edit", goal_id=goal_id)) + # Update goal old_target = goal.target_hours goal.target_hours = target_hours goal.notes = notes - - if status and status in ['active', 'completed', 'failed', 'cancelled']: + + if status and status in ["active", "completed", "failed", "cancelled"]: goal.status = status - + if safe_commit(db.session): - flash(_('Weekly time goal updated successfully!'), 'success') + flash(_("Weekly time goal updated successfully!"), "success") log_event( - 'weekly_goal.updated', + "weekly_goal.updated", user_id=current_user.id, - resource_type='weekly_goal', + resource_type="weekly_goal", resource_id=goal.id, old_target=old_target, new_target=target_hours, - week_label=goal.week_label + week_label=goal.week_label, ) track_event( user_id=current_user.id, - event_name='weekly_goal_updated', - properties={'goal_id': goal_id, 'new_target': target_hours} + event_name="weekly_goal_updated", + properties={"goal_id": goal_id, "new_target": target_hours}, ) - return redirect(url_for('weekly_goals.view', goal_id=goal_id)) + return redirect(url_for("weekly_goals.view", goal_id=goal_id)) else: - flash(_('Failed to update goal. Please try again.'), 'error') - return redirect(url_for('weekly_goals.edit', goal_id=goal_id)) + flash(_("Failed to update goal. Please try again."), "error") + return redirect(url_for("weekly_goals.edit", goal_id=goal_id)) -@weekly_goals_bp.route('/goals//delete', methods=['POST']) +@weekly_goals_bp.route("/goals//delete", methods=["POST"]) @login_required def delete(goal_id): """Delete a weekly time goal""" current_app.logger.info(f"POST /goals/{goal_id}/delete user={current_user.username}") - + goal = WeeklyTimeGoal.query.get_or_404(goal_id) - + # Ensure user can only delete their own goals if goal.user_id != current_user.id: - flash(_('You do not have permission to delete this goal'), 'error') - return redirect(url_for('weekly_goals.index')) - + flash(_("You do not have permission to delete this goal"), "error") + return redirect(url_for("weekly_goals.index")) + week_label = goal.week_label - + db.session.delete(goal) - + if safe_commit(db.session): - flash(_('Weekly time goal deleted successfully'), 'success') + flash(_("Weekly time goal deleted successfully"), "success") log_event( - 'weekly_goal.deleted', + "weekly_goal.deleted", user_id=current_user.id, - resource_type='weekly_goal', + resource_type="weekly_goal", resource_id=goal_id, - week_label=week_label - ) - track_event( - user_id=current_user.id, - event_name='weekly_goal_deleted', - properties={'goal_id': goal_id} + week_label=week_label, ) + track_event(user_id=current_user.id, event_name="weekly_goal_deleted", properties={"goal_id": goal_id}) else: - flash(_('Failed to delete goal. Please try again.'), 'error') - - return redirect(url_for('weekly_goals.index')) + flash(_("Failed to delete goal. Please try again."), "error") + + return redirect(url_for("weekly_goals.index")) # API Endpoints -@weekly_goals_bp.route('/api/goals/current') + +@weekly_goals_bp.route("/api/goals/current") @login_required def api_current_goal(): """API endpoint to get current week's goal""" current_app.logger.info(f"GET /api/goals/current user={current_user.username}") - + goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) - + if goal: goal.update_status() return jsonify(goal.to_dict()) else: - return jsonify({'error': 'No goal set for current week'}), 404 + return jsonify({"error": "No goal set for current week"}), 404 -@weekly_goals_bp.route('/api/goals') +@weekly_goals_bp.route("/api/goals") @login_required def api_list_goals(): """API endpoint to list all goals for current user""" current_app.logger.info(f"GET /api/goals user={current_user.username}") - - limit = request.args.get('limit', 12, type=int) - status_filter = request.args.get('status') - + + limit = request.args.get("limit", 12, type=int) + status_filter = request.args.get("status") + query = WeeklyTimeGoal.query.filter_by(user_id=current_user.id) - + if status_filter: query = query.filter_by(status=status_filter) - - goals = query.order_by( - WeeklyTimeGoal.week_start_date.desc() - ).limit(limit).all() - + + goals = query.order_by(WeeklyTimeGoal.week_start_date.desc()).limit(limit).all() + # Update status for all goals for goal in goals: goal.update_status() - + return jsonify([goal.to_dict() for goal in goals]) -@weekly_goals_bp.route('/api/goals/') +@weekly_goals_bp.route("/api/goals/") @login_required def api_get_goal(goal_id): """API endpoint to get a specific goal""" current_app.logger.info(f"GET /api/goals/{goal_id} user={current_user.username}") - + goal = WeeklyTimeGoal.query.get_or_404(goal_id) - + # Ensure user can only view their own goals if goal.user_id != current_user.id: - return jsonify({'error': 'Unauthorized'}), 403 - + return jsonify({"error": "Unauthorized"}), 403 + goal.update_status() return jsonify(goal.to_dict()) -@weekly_goals_bp.route('/api/goals/stats') +@weekly_goals_bp.route("/api/goals/stats") @login_required def api_stats(): """API endpoint to get goal statistics""" current_app.logger.info(f"GET /api/goals/stats user={current_user.username}") - + # Get all goals for the user - goals = WeeklyTimeGoal.query.filter_by( - user_id=current_user.id - ).order_by( - WeeklyTimeGoal.week_start_date.desc() - ).all() - + goals = ( + WeeklyTimeGoal.query.filter_by(user_id=current_user.id).order_by(WeeklyTimeGoal.week_start_date.desc()).all() + ) + # Update status for all goals for goal in goals: goal.update_status() - + # Calculate statistics total = len(goals) - completed = sum(1 for g in goals if g.status == 'completed') - failed = sum(1 for g in goals if g.status == 'failed') - active = sum(1 for g in goals if g.status == 'active') - cancelled = sum(1 for g in goals if g.status == 'cancelled') - + completed = sum(1 for g in goals if g.status == "completed") + failed = sum(1 for g in goals if g.status == "failed") + active = sum(1 for g in goals if g.status == "active") + cancelled = sum(1 for g in goals if g.status == "cancelled") + completion_rate = 0 if total > 0: completed_or_failed = completed + failed if completed_or_failed > 0: completion_rate = round((completed / completed_or_failed) * 100, 1) - + # Calculate average target hours avg_target = 0 if total > 0: avg_target = round(sum(g.target_hours for g in goals) / total, 2) - + # Calculate average actual hours avg_actual = 0 if total > 0: avg_actual = round(sum(g.actual_hours for g in goals) / total, 2) - + # Get current streak (consecutive weeks with completed goals) current_streak = 0 for goal in goals: - if goal.status == 'completed': + if goal.status == "completed": current_streak += 1 - elif goal.status in ['failed', 'cancelled']: + elif goal.status in ["failed", "cancelled"]: break - - return jsonify({ - 'total_goals': total, - 'completed': completed, - 'failed': failed, - 'active': active, - 'cancelled': cancelled, - 'completion_rate': completion_rate, - 'average_target_hours': avg_target, - 'average_actual_hours': avg_actual, - 'current_streak': current_streak - }) + return jsonify( + { + "total_goals": total, + "completed": completed, + "failed": failed, + "active": active, + "cancelled": cancelled, + "completion_rate": completion_rate, + "average_target_hours": avg_target, + "average_actual_hours": avg_actual, + "current_streak": current_streak, + } + ) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 70091710..1962c431 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -14,32 +14,31 @@ from .user_schema import UserSchema, UserCreateSchema, UserUpdateSchema __all__ = [ - 'TimeEntrySchema', - 'TimeEntryCreateSchema', - 'TimeEntryUpdateSchema', - 'ProjectSchema', - 'ProjectCreateSchema', - 'ProjectUpdateSchema', - 'InvoiceSchema', - 'InvoiceCreateSchema', - 'InvoiceUpdateSchema', - 'TaskSchema', - 'TaskCreateSchema', - 'TaskUpdateSchema', - 'ExpenseSchema', - 'ExpenseCreateSchema', - 'ExpenseUpdateSchema', - 'ClientSchema', - 'ClientCreateSchema', - 'ClientUpdateSchema', - 'PaymentSchema', - 'PaymentCreateSchema', - 'PaymentUpdateSchema', - 'CommentSchema', - 'CommentCreateSchema', - 'CommentUpdateSchema', - 'UserSchema', - 'UserCreateSchema', - 'UserUpdateSchema', + "TimeEntrySchema", + "TimeEntryCreateSchema", + "TimeEntryUpdateSchema", + "ProjectSchema", + "ProjectCreateSchema", + "ProjectUpdateSchema", + "InvoiceSchema", + "InvoiceCreateSchema", + "InvoiceUpdateSchema", + "TaskSchema", + "TaskCreateSchema", + "TaskUpdateSchema", + "ExpenseSchema", + "ExpenseCreateSchema", + "ExpenseUpdateSchema", + "ClientSchema", + "ClientCreateSchema", + "ClientUpdateSchema", + "PaymentSchema", + "PaymentCreateSchema", + "PaymentUpdateSchema", + "CommentSchema", + "CommentCreateSchema", + "CommentUpdateSchema", + "UserSchema", + "UserCreateSchema", + "UserUpdateSchema", ] - diff --git a/app/schemas/client_schema.py b/app/schemas/client_schema.py index 45f7a741..73d43977 100644 --- a/app/schemas/client_schema.py +++ b/app/schemas/client_schema.py @@ -8,6 +8,7 @@ class ClientSchema(Schema): """Schema for client serialization""" + id = fields.Int(dump_only=True) name = fields.Str(required=True, validate=validate.Length(max=200)) email = fields.Email(allow_none=True) @@ -15,31 +16,32 @@ class ClientSchema(Schema): phone = fields.Str(allow_none=True, validate=validate.Length(max=50)) address = fields.Str(allow_none=True) default_hourly_rate = fields.Decimal(allow_none=True, places=2) - status = fields.Str(validate=validate.OneOf(['active', 'inactive', 'archived'])) + status = fields.Str(validate=validate.OneOf(["active", "inactive", "archived"])) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields - projects = fields.Nested('ProjectSchema', many=True, dump_only=True, allow_none=True) + projects = fields.Nested("ProjectSchema", many=True, dump_only=True, allow_none=True) class ClientCreateSchema(Schema): """Schema for creating a client""" + name = fields.Str(required=True, validate=validate.Length(min=1, max=200)) email = fields.Email(allow_none=True) company = fields.Str(allow_none=True, validate=validate.Length(max=200)) phone = fields.Str(allow_none=True, validate=validate.Length(max=50)) address = fields.Str(allow_none=True) - default_hourly_rate = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal('0'))) + default_hourly_rate = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0"))) class ClientUpdateSchema(Schema): """Schema for updating a client""" + name = fields.Str(allow_none=True, validate=validate.Length(min=1, max=200)) email = fields.Email(allow_none=True) company = fields.Str(allow_none=True, validate=validate.Length(max=200)) phone = fields.Str(allow_none=True, validate=validate.Length(max=50)) address = fields.Str(allow_none=True) - default_hourly_rate = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal('0'))) - status = fields.Str(allow_none=True, validate=validate.OneOf(['active', 'inactive', 'archived'])) - + default_hourly_rate = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0"))) + status = fields.Str(allow_none=True, validate=validate.OneOf(["active", "inactive", "archived"])) diff --git a/app/schemas/comment_schema.py b/app/schemas/comment_schema.py index f2d0002b..ad9d16b8 100644 --- a/app/schemas/comment_schema.py +++ b/app/schemas/comment_schema.py @@ -7,6 +7,7 @@ class CommentSchema(Schema): """Schema for comment serialization""" + id = fields.Int(dump_only=True) content = fields.Str(required=True, validate=validate.Length(min=1, max=5000)) project_id = fields.Int(allow_none=True) @@ -17,16 +18,17 @@ class CommentSchema(Schema): parent_id = fields.Int(allow_none=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields - author = fields.Nested('UserSchema', dump_only=True, allow_none=True) - project = fields.Nested('ProjectSchema', dump_only=True, allow_none=True) - task = fields.Nested('TaskSchema', dump_only=True, allow_none=True) - replies = fields.Nested('CommentSchema', many=True, dump_only=True, allow_none=True) + author = fields.Nested("UserSchema", dump_only=True, allow_none=True) + project = fields.Nested("ProjectSchema", dump_only=True, allow_none=True) + task = fields.Nested("TaskSchema", dump_only=True, allow_none=True) + replies = fields.Nested("CommentSchema", many=True, dump_only=True, allow_none=True) class CommentCreateSchema(Schema): """Schema for creating a comment""" + content = fields.Str(required=True, validate=validate.Length(min=1, max=5000)) project_id = fields.Int(allow_none=True) task_id = fields.Int(allow_none=True) @@ -37,6 +39,6 @@ class CommentCreateSchema(Schema): class CommentUpdateSchema(Schema): """Schema for updating a comment""" + content = fields.Str(allow_none=True, validate=validate.Length(min=1, max=5000)) is_internal = fields.Bool(allow_none=True) - diff --git a/app/schemas/expense_schema.py b/app/schemas/expense_schema.py index 4724bca9..093d5ef6 100644 --- a/app/schemas/expense_schema.py +++ b/app/schemas/expense_schema.py @@ -8,6 +8,7 @@ class ExpenseSchema(Schema): """Schema for expense serialization""" + id = fields.Int(dump_only=True) project_id = fields.Int(required=True) amount = fields.Decimal(required=True, places=2) @@ -19,16 +20,17 @@ class ExpenseSchema(Schema): created_by = fields.Int(required=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields - project = fields.Nested('ProjectSchema', dump_only=True, allow_none=True) - category = fields.Nested('ExpenseCategorySchema', dump_only=True, allow_none=True) + project = fields.Nested("ProjectSchema", dump_only=True, allow_none=True) + category = fields.Nested("ExpenseCategorySchema", dump_only=True, allow_none=True) class ExpenseCreateSchema(Schema): """Schema for creating an expense""" + project_id = fields.Int(required=True) - amount = fields.Decimal(required=True, places=2, validate=validate.Range(min=Decimal('0.01'))) + amount = fields.Decimal(required=True, places=2, validate=validate.Range(min=Decimal("0.01"))) description = fields.Str(required=True, validate=validate.Length(min=1, max=500)) date = fields.Date(required=True) category_id = fields.Int(allow_none=True) @@ -38,11 +40,11 @@ class ExpenseCreateSchema(Schema): class ExpenseUpdateSchema(Schema): """Schema for updating an expense""" + project_id = fields.Int(allow_none=True) - amount = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal('0.01'))) + amount = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0.01"))) description = fields.Str(allow_none=True, validate=validate.Length(max=500)) date = fields.Date(allow_none=True) category_id = fields.Int(allow_none=True) billable = fields.Bool(allow_none=True) receipt_path = fields.Str(allow_none=True) - diff --git a/app/schemas/invoice_schema.py b/app/schemas/invoice_schema.py index e1ac6098..fee5f680 100644 --- a/app/schemas/invoice_schema.py +++ b/app/schemas/invoice_schema.py @@ -9,6 +9,7 @@ class InvoiceItemSchema(Schema): """Schema for invoice item serialization""" + id = fields.Int(dump_only=True) invoice_id = fields.Int(dump_only=True) description = fields.Str(required=True) @@ -19,6 +20,7 @@ class InvoiceItemSchema(Schema): class InvoiceSchema(Schema): """Schema for invoice serialization""" + id = fields.Int(dump_only=True) invoice_number = fields.Str(required=True) project_id = fields.Int(required=True) @@ -45,14 +47,15 @@ class InvoiceSchema(Schema): created_by = fields.Int(required=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields - project = fields.Nested('ProjectSchema', dump_only=True, allow_none=True) + project = fields.Nested("ProjectSchema", dump_only=True, allow_none=True) items = fields.Nested(InvoiceItemSchema, many=True, dump_only=True, allow_none=True) class InvoiceCreateSchema(Schema): """Schema for creating an invoice""" + project_id = fields.Int(required=True) issue_date = fields.Date(allow_none=True) due_date = fields.Date(allow_none=True) @@ -64,9 +67,9 @@ class InvoiceCreateSchema(Schema): class InvoiceUpdateSchema(Schema): """Schema for updating an invoice""" + issue_date = fields.Date(allow_none=True) due_date = fields.Date(allow_none=True) status = fields.Str(allow_none=True, validate=validate.OneOf([s.value for s in InvoiceStatus])) notes = fields.Str(allow_none=True) terms = fields.Str(allow_none=True) - diff --git a/app/schemas/payment_schema.py b/app/schemas/payment_schema.py index 45d4eb82..727fbd67 100644 --- a/app/schemas/payment_schema.py +++ b/app/schemas/payment_schema.py @@ -9,6 +9,7 @@ class PaymentSchema(Schema): """Schema for payment serialization""" + id = fields.Int(dump_only=True) invoice_id = fields.Int(required=True) amount = fields.Decimal(required=True, places=2) @@ -17,42 +18,43 @@ class PaymentSchema(Schema): method = fields.Str(allow_none=True) reference = fields.Str(allow_none=True, validate=validate.Length(max=100)) notes = fields.Str(allow_none=True) - status = fields.Str(validate=validate.OneOf(['completed', 'pending', 'failed', 'refunded'])) + status = fields.Str(validate=validate.OneOf(["completed", "pending", "failed", "refunded"])) received_by = fields.Int(allow_none=True) gateway_transaction_id = fields.Str(allow_none=True) gateway_fee = fields.Decimal(allow_none=True, places=2) net_amount = fields.Decimal(allow_none=True, places=2) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields - invoice = fields.Nested('InvoiceSchema', dump_only=True, allow_none=True) - receiver = fields.Nested('UserSchema', dump_only=True, allow_none=True) + invoice = fields.Nested("InvoiceSchema", dump_only=True, allow_none=True) + receiver = fields.Nested("UserSchema", dump_only=True, allow_none=True) class PaymentCreateSchema(Schema): """Schema for creating a payment""" + invoice_id = fields.Int(required=True) - amount = fields.Decimal(required=True, places=2, validate=validate.Range(min=Decimal('0.01'))) + amount = fields.Decimal(required=True, places=2, validate=validate.Range(min=Decimal("0.01"))) currency = fields.Str(allow_none=True, validate=validate.Length(equal=3)) payment_date = fields.Date(required=True) method = fields.Str(allow_none=True) reference = fields.Str(allow_none=True, validate=validate.Length(max=100)) notes = fields.Str(allow_none=True) - status = fields.Str(missing='completed', validate=validate.OneOf(['completed', 'pending', 'failed', 'refunded'])) + status = fields.Str(missing="completed", validate=validate.OneOf(["completed", "pending", "failed", "refunded"])) gateway_transaction_id = fields.Str(allow_none=True) - gateway_fee = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal('0'))) + gateway_fee = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0"))) class PaymentUpdateSchema(Schema): """Schema for updating a payment""" - amount = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal('0.01'))) + + amount = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0.01"))) currency = fields.Str(allow_none=True, validate=validate.Length(equal=3)) payment_date = fields.Date(allow_none=True) method = fields.Str(allow_none=True) reference = fields.Str(allow_none=True, validate=validate.Length(max=100)) notes = fields.Str(allow_none=True) - status = fields.Str(allow_none=True, validate=validate.OneOf(['completed', 'pending', 'failed', 'refunded'])) + status = fields.Str(allow_none=True, validate=validate.OneOf(["completed", "pending", "failed", "refunded"])) gateway_transaction_id = fields.Str(allow_none=True) - gateway_fee = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal('0'))) - + gateway_fee = fields.Decimal(allow_none=True, places=2, validate=validate.Range(min=Decimal("0"))) diff --git a/app/schemas/project_schema.py b/app/schemas/project_schema.py index fd165c52..ced1f3f4 100644 --- a/app/schemas/project_schema.py +++ b/app/schemas/project_schema.py @@ -9,6 +9,7 @@ class ProjectSchema(Schema): """Schema for project serialization""" + id = fields.Int(dump_only=True) name = fields.Str(required=True, validate=validate.Length(max=200)) client_id = fields.Int(required=True) @@ -27,14 +28,15 @@ class ProjectSchema(Schema): archived_at = fields.DateTime(dump_only=True, allow_none=True) archived_by = fields.Int(dump_only=True, allow_none=True) archived_reason = fields.Str(dump_only=True, allow_none=True) - + # Nested fields - client = fields.Nested('ClientSchema', dump_only=True, allow_none=True) - time_entries = fields.Nested('TimeEntrySchema', many=True, dump_only=True, allow_none=True) + client = fields.Nested("ClientSchema", dump_only=True, allow_none=True) + time_entries = fields.Nested("TimeEntrySchema", many=True, dump_only=True, allow_none=True) class ProjectCreateSchema(Schema): """Schema for creating a project""" + name = fields.Str(required=True, validate=validate.Length(min=1, max=200)) client_id = fields.Int(required=True) description = fields.Str(allow_none=True) @@ -49,6 +51,7 @@ class ProjectCreateSchema(Schema): class ProjectUpdateSchema(Schema): """Schema for updating a project""" + name = fields.Str(allow_none=True, validate=validate.Length(min=1, max=200)) client_id = fields.Int(allow_none=True) description = fields.Str(allow_none=True) @@ -60,4 +63,3 @@ class ProjectUpdateSchema(Schema): estimated_hours = fields.Float(allow_none=True) budget_amount = fields.Decimal(allow_none=True, places=2) budget_threshold_percent = fields.Int(allow_none=True, validate=validate.Range(min=0, max=100)) - diff --git a/app/schemas/task_schema.py b/app/schemas/task_schema.py index daad1886..ab3e1a29 100644 --- a/app/schemas/task_schema.py +++ b/app/schemas/task_schema.py @@ -8,39 +8,41 @@ class TaskSchema(Schema): """Schema for task serialization""" + id = fields.Int(dump_only=True) name = fields.Str(required=True, validate=validate.Length(max=200)) description = fields.Str(allow_none=True) project_id = fields.Int(required=True) assignee_id = fields.Int(allow_none=True) status = fields.Str(validate=validate.OneOf([s.value for s in TaskStatus])) - priority = fields.Str(validate=validate.OneOf(['low', 'medium', 'high', 'urgent'])) + priority = fields.Str(validate=validate.OneOf(["low", "medium", "high", "urgent"])) due_date = fields.Date(allow_none=True) created_by = fields.Int(required=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields - project = fields.Nested('ProjectSchema', dump_only=True, allow_none=True) - assignee = fields.Nested('UserSchema', dump_only=True, allow_none=True) + project = fields.Nested("ProjectSchema", dump_only=True, allow_none=True) + assignee = fields.Nested("UserSchema", dump_only=True, allow_none=True) class TaskCreateSchema(Schema): """Schema for creating a task""" + name = fields.Str(required=True, validate=validate.Length(min=1, max=200)) description = fields.Str(allow_none=True) project_id = fields.Int(required=True) assignee_id = fields.Int(allow_none=True) - priority = fields.Str(missing='medium', validate=validate.OneOf(['low', 'medium', 'high', 'urgent'])) + priority = fields.Str(missing="medium", validate=validate.OneOf(["low", "medium", "high", "urgent"])) due_date = fields.Date(allow_none=True) class TaskUpdateSchema(Schema): """Schema for updating a task""" + name = fields.Str(allow_none=True, validate=validate.Length(min=1, max=200)) description = fields.Str(allow_none=True) assignee_id = fields.Int(allow_none=True) status = fields.Str(allow_none=True, validate=validate.OneOf([s.value for s in TaskStatus])) - priority = fields.Str(allow_none=True, validate=validate.OneOf(['low', 'medium', 'high', 'urgent'])) + priority = fields.Str(allow_none=True, validate=validate.OneOf(["low", "medium", "high", "urgent"])) due_date = fields.Date(allow_none=True) - diff --git a/app/schemas/time_entry_schema.py b/app/schemas/time_entry_schema.py index 84478a42..cb10cb94 100644 --- a/app/schemas/time_entry_schema.py +++ b/app/schemas/time_entry_schema.py @@ -9,6 +9,7 @@ class TimeEntrySchema(Schema): """Schema for time entry serialization""" + id = fields.Int(dump_only=True) user_id = fields.Int(required=True) project_id = fields.Int(required=True) @@ -22,15 +23,16 @@ class TimeEntrySchema(Schema): billable = fields.Bool(missing=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields (when relations are loaded) - project = fields.Nested('ProjectSchema', dump_only=True, allow_none=True) - user = fields.Nested('UserSchema', dump_only=True, allow_none=True) - task = fields.Nested('TaskSchema', dump_only=True, allow_none=True) + project = fields.Nested("ProjectSchema", dump_only=True, allow_none=True) + user = fields.Nested("UserSchema", dump_only=True, allow_none=True) + task = fields.Nested("TaskSchema", dump_only=True, allow_none=True) class TimeEntryCreateSchema(Schema): """Schema for creating a time entry""" + project_id = fields.Int(required=True) task_id = fields.Int(allow_none=True) start_time = fields.DateTime(required=True) @@ -38,18 +40,19 @@ class TimeEntryCreateSchema(Schema): notes = fields.Str(allow_none=True, validate=validate.Length(max=5000)) tags = fields.Str(allow_none=True, validate=validate.Length(max=500)) billable = fields.Bool(missing=True) - - @validates('end_time') + + @validates("end_time") def validate_end_time(self, value, **kwargs): """Validate that end_time is after start_time""" - data = kwargs.get('data', {}) - start_time = data.get('start_time') + data = kwargs.get("data", {}) + start_time = data.get("start_time") if start_time and value and value <= start_time: - raise ValidationError('end_time must be after start_time') + raise ValidationError("end_time must be after start_time") class TimeEntryUpdateSchema(Schema): """Schema for updating a time entry""" + project_id = fields.Int(allow_none=True) task_id = fields.Int(allow_none=True) start_time = fields.DateTime(allow_none=True) @@ -61,6 +64,7 @@ class TimeEntryUpdateSchema(Schema): class TimerStartSchema(Schema): """Schema for starting a timer""" + project_id = fields.Int(required=True) task_id = fields.Int(allow_none=True) notes = fields.Str(allow_none=True, validate=validate.Length(max=5000)) @@ -69,5 +73,5 @@ class TimerStartSchema(Schema): class TimerStopSchema(Schema): """Schema for stopping a timer""" - entry_id = fields.Int(allow_none=True) # Optional, will use active timer if not provided + entry_id = fields.Int(allow_none=True) # Optional, will use active timer if not provided diff --git a/app/schemas/user_schema.py b/app/schemas/user_schema.py index a784231a..7ce7f687 100644 --- a/app/schemas/user_schema.py +++ b/app/schemas/user_schema.py @@ -8,6 +8,7 @@ class UserSchema(Schema): """Schema for user serialization""" + id = fields.Int(dump_only=True) username = fields.Str(required=True, validate=validate.Length(max=100)) email = fields.Email(allow_none=True) @@ -17,13 +18,14 @@ class UserSchema(Schema): preferred_language = fields.Str(allow_none=True) created_at = fields.DateTime(dump_only=True) updated_at = fields.DateTime(dump_only=True) - + # Nested fields (when relations are loaded) - favorite_projects = fields.Nested('ProjectSchema', many=True, dump_only=True, allow_none=True) + favorite_projects = fields.Nested("ProjectSchema", many=True, dump_only=True, allow_none=True) class UserCreateSchema(Schema): """Schema for creating a user""" + username = fields.Str(required=True, validate=validate.Length(min=1, max=100)) email = fields.Email(allow_none=True) full_name = fields.Str(allow_none=True, validate=validate.Length(max=200)) @@ -34,10 +36,10 @@ class UserCreateSchema(Schema): class UserUpdateSchema(Schema): """Schema for updating a user""" + username = fields.Str(allow_none=True, validate=validate.Length(min=1, max=100)) email = fields.Email(allow_none=True) full_name = fields.Str(allow_none=True, validate=validate.Length(max=200)) role = fields.Str(allow_none=True, validate=validate.OneOf([r.value for r in UserRole])) is_active = fields.Bool(allow_none=True) preferred_language = fields.Str(allow_none=True) - diff --git a/app/services/__init__.py b/app/services/__init__.py index fd9a0be8..e2551831 100644 --- a/app/services/__init__.py +++ b/app/services/__init__.py @@ -23,23 +23,22 @@ from .health_service import HealthService __all__ = [ - 'TimeTrackingService', - 'ProjectService', - 'InvoiceService', - 'NotificationService', - 'TaskService', - 'ExpenseService', - 'ClientService', - 'ReportingService', - 'AnalyticsService', - 'PaymentService', - 'CommentService', - 'UserService', - 'ExportService', - 'ImportService', - 'EmailService', - 'PermissionService', - 'BackupService', - 'HealthService', + "TimeTrackingService", + "ProjectService", + "InvoiceService", + "NotificationService", + "TaskService", + "ExpenseService", + "ClientService", + "ReportingService", + "AnalyticsService", + "PaymentService", + "CommentService", + "UserService", + "ExportService", + "ImportService", + "EmailService", + "PermissionService", + "BackupService", + "HealthService", ] - diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py index 5e27d5b4..b39414a8 100644 --- a/app/services/analytics_service.py +++ b/app/services/analytics_service.py @@ -5,105 +5,82 @@ from typing import Dict, Any, List, Optional from datetime import datetime, timedelta from decimal import Decimal -from app.repositories import ( - TimeEntryRepository, - ProjectRepository, - InvoiceRepository, - ExpenseRepository -) +from app.repositories import TimeEntryRepository, ProjectRepository, InvoiceRepository, ExpenseRepository class AnalyticsService: """Service for analytics operations""" - + def __init__(self): self.time_entry_repo = TimeEntryRepository() self.project_repo = ProjectRepository() self.invoice_repo = InvoiceRepository() self.expense_repo = ExpenseRepository() - - def get_dashboard_stats( - self, - user_id: Optional[int] = None - ) -> Dict[str, Any]: + + def get_dashboard_stats(self, user_id: Optional[int] = None) -> Dict[str, Any]: """ Get dashboard statistics. - + Returns: dict with dashboard metrics """ today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0) week_start = today - timedelta(days=today.weekday()) month_start = today.replace(day=1) - + # Today's time today_seconds = self.time_entry_repo.get_total_duration( - user_id=user_id, - start_date=today, - end_date=datetime.now() + user_id=user_id, start_date=today, end_date=datetime.now() ) - + # This week's time week_seconds = self.time_entry_repo.get_total_duration( - user_id=user_id, - start_date=week_start, - end_date=datetime.now() + user_id=user_id, start_date=week_start, end_date=datetime.now() ) - + # This month's time month_seconds = self.time_entry_repo.get_total_duration( - user_id=user_id, - start_date=month_start, - end_date=datetime.now() + user_id=user_id, start_date=month_start, end_date=datetime.now() ) - + # Active projects active_projects = self.project_repo.get_active_projects(user_id=user_id) - + # Recent invoices - recent_invoices = self.invoice_repo.get_by_status('sent', include_relations=False)[:5] - + recent_invoices = self.invoice_repo.get_by_status("sent", include_relations=False)[:5] + # Overdue invoices overdue_invoices = self.invoice_repo.get_overdue(include_relations=False) - + return { - 'time_tracking': { - 'today_hours': round(today_seconds / 3600, 2), - 'week_hours': round(week_seconds / 3600, 2), - 'month_hours': round(month_seconds / 3600, 2) + "time_tracking": { + "today_hours": round(today_seconds / 3600, 2), + "week_hours": round(week_seconds / 3600, 2), + "month_hours": round(month_seconds / 3600, 2), }, - 'projects': { - 'active_count': len(active_projects) + "projects": {"active_count": len(active_projects)}, + "invoices": { + "recent_count": len(recent_invoices), + "overdue_count": len(overdue_invoices), + "overdue_amount": sum(float(inv.total_amount - (inv.amount_paid or 0)) for inv in overdue_invoices), }, - 'invoices': { - 'recent_count': len(recent_invoices), - 'overdue_count': len(overdue_invoices), - 'overdue_amount': sum(float(inv.total_amount - (inv.amount_paid or 0)) for inv in overdue_invoices) - } } - - def get_trends( - self, - user_id: Optional[int] = None, - days: int = 30 - ) -> Dict[str, Any]: + + def get_trends(self, user_id: Optional[int] = None, days: int = 30) -> Dict[str, Any]: """ Get time tracking trends. - + Returns: dict with daily/hourly trends """ end_date = datetime.now() start_date = end_date - timedelta(days=days) - + # Get entries entries = self.time_entry_repo.get_by_date_range( - start_date=start_date, - end_date=end_date, - user_id=user_id, - include_relations=False + start_date=start_date, end_date=end_date, user_id=user_id, include_relations=False ) - + # Group by date daily_hours = {} for entry in entries: @@ -112,25 +89,21 @@ def get_trends( if entry_date not in daily_hours: daily_hours[entry_date] = 0 daily_hours[entry_date] += hours - + # Create trend data trend_data = [] current_date = start_date.date() while current_date <= end_date.date(): - trend_data.append({ - 'date': current_date.isoformat(), - 'hours': round(daily_hours.get(current_date, 0), 2) - }) + trend_data.append({"date": current_date.isoformat(), "hours": round(daily_hours.get(current_date, 0), 2)}) current_date += timedelta(days=1) - + return { - 'period': { - 'start_date': start_date.date().isoformat(), - 'end_date': end_date.date().isoformat(), - 'days': days + "period": { + "start_date": start_date.date().isoformat(), + "end_date": end_date.date().isoformat(), + "days": days, }, - 'daily_trends': trend_data, - 'total_hours': round(sum(daily_hours.values()), 2), - 'average_daily_hours': round(sum(daily_hours.values()) / days, 2) if days > 0 else 0 + "daily_trends": trend_data, + "total_hours": round(sum(daily_hours.values()), 2), + "average_daily_hours": round(sum(daily_hours.values()) / days, 2) if days > 0 else 0, } - diff --git a/app/services/api_token_service.py b/app/services/api_token_service.py index 5be2cbf6..fe196579 100644 --- a/app/services/api_token_service.py +++ b/app/services/api_token_service.py @@ -14,20 +14,20 @@ class ApiTokenService: """ Service for API token management with enhanced security features. - + This service handles all API token operations including: - Creating tokens with scope validation - Token rotation for security - Token revocation - Expiration management - Rate limiting (foundation for Redis integration) - + Security features: - Scope-based permissions - Token expiration - IP whitelisting support - Usage tracking - + Example: service = ApiTokenService() result = service.create_token( @@ -39,19 +39,19 @@ class ApiTokenService: if result['success']: token = result['token'] # Only shown once! """ - + def create_token( self, user_id: int, name: str, - description: str = '', - scopes: str = '', + description: str = "", + scopes: str = "", expires_days: Optional[int] = None, - ip_whitelist: Optional[str] = None + ip_whitelist: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new API token with enhanced security. - + Args: user_id: User ID who owns this token name: Human-readable name for the token @@ -59,258 +59,227 @@ def create_token( scopes: Comma-separated list of scopes expires_days: Number of days until expiration (None = never expires) ip_whitelist: Comma-separated list of allowed IPs/CIDR blocks - + Returns: dict with 'success', 'message', 'token', and 'api_token' keys """ # Validate user exists user = User.query.get(user_id) if not user: - return { - 'success': False, - 'message': 'Invalid user', - 'error': 'invalid_user' - } - + return {"success": False, "message": "Invalid user", "error": "invalid_user"} + # Validate scopes if provided if scopes: validation_result = self.validate_scopes(scopes) - if not validation_result['valid']: + if not validation_result["valid"]: return { - 'success': False, - 'message': f"Invalid scopes: {', '.join(validation_result['invalid'])}", - 'error': 'invalid_scopes', - 'invalid_scopes': validation_result['invalid'] + "success": False, + "message": f"Invalid scopes: {', '.join(validation_result['invalid'])}", + "error": "invalid_scopes", + "invalid_scopes": validation_result["invalid"], } - + # Create token try: api_token, plain_token = ApiToken.create_token( - user_id=user_id, - name=name, - description=description, - scopes=scopes, - expires_days=expires_days + user_id=user_id, name=name, description=description, scopes=scopes, expires_days=expires_days ) - + if ip_whitelist: api_token.ip_whitelist = ip_whitelist - + db.session.add(api_token) - - if not safe_commit('create_api_token', {'user_id': user_id, 'name': name}): + + if not safe_commit("create_api_token", {"user_id": user_id, "name": name}): return { - 'success': False, - 'message': 'Could not create API token due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create API token due to a database error", + "error": "database_error", } - + # Emit event - emit_event(WebhookEvent.API_TOKEN_CREATED.value, { - 'token_id': api_token.id, - 'user_id': user_id - }) - + emit_event(WebhookEvent.API_TOKEN_CREATED.value, {"token_id": api_token.id, "user_id": user_id}) + return { - 'success': True, - 'message': 'API token created successfully', - 'token': plain_token, # Only returned once! - 'api_token': api_token + "success": True, + "message": "API token created successfully", + "token": plain_token, # Only returned once! + "api_token": api_token, } except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error creating API token: {str(e)}', - 'error': 'creation_error' - } - + return {"success": False, "message": f"Error creating API token: {str(e)}", "error": "creation_error"} + def rotate_token(self, token_id: int, user_id: int) -> Dict[str, Any]: """ Rotate an API token by creating a new one and deactivating the old one. - + Args: token_id: The token ID to rotate user_id: User ID requesting the rotation (must own the token) - + Returns: dict with 'success', 'message', 'new_token', and 'api_token' keys """ # Get existing token api_token = ApiToken.query.get(token_id) if not api_token: - return { - 'success': False, - 'message': 'Token not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Token not found", "error": "not_found"} + # Verify ownership if api_token.user_id != user_id: return { - 'success': False, - 'message': 'You do not have permission to rotate this token', - 'error': 'permission_denied' + "success": False, + "message": "You do not have permission to rotate this token", + "error": "permission_denied", } - + # Create new token with same scopes and settings result = self.create_token( user_id=api_token.user_id, name=f"{api_token.name} (rotated)", description=f"Rotated from token {api_token.token_prefix}...", - scopes=api_token.scopes or '', + scopes=api_token.scopes or "", expires_days=None, # Keep same expiration policy - ip_whitelist=api_token.ip_whitelist + ip_whitelist=api_token.ip_whitelist, ) - - if not result['success']: + + if not result["success"]: return result - + # Deactivate old token api_token.is_active = False - api_token.description = f"{api_token.description or ''} (Rotated and replaced by {result['api_token'].token_prefix}...)".strip() - - if not safe_commit('rotate_api_token', {'token_id': token_id, 'new_token_id': result['api_token'].id}): + api_token.description = ( + f"{api_token.description or ''} (Rotated and replaced by {result['api_token'].token_prefix}...)".strip() + ) + + if not safe_commit("rotate_api_token", {"token_id": token_id, "new_token_id": result["api_token"].id}): return { - 'success': False, - 'message': 'Could not complete token rotation due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not complete token rotation due to a database error", + "error": "database_error", } - + # Emit event - emit_event(WebhookEvent.API_TOKEN_ROTATED.value, { - 'old_token_id': token_id, - 'new_token_id': result['api_token'].id, - 'user_id': user_id - }) - + emit_event( + WebhookEvent.API_TOKEN_ROTATED.value, + {"old_token_id": token_id, "new_token_id": result["api_token"].id, "user_id": user_id}, + ) + return { - 'success': True, - 'message': 'Token rotated successfully', - 'new_token': result['token'], - 'api_token': result['api_token'], - 'old_token': api_token + "success": True, + "message": "Token rotated successfully", + "new_token": result["token"], + "api_token": result["api_token"], + "old_token": api_token, } - + def revoke_token(self, token_id: int, user_id: int) -> Dict[str, Any]: """ Revoke (deactivate) an API token. - + Args: token_id: The token ID to revoke user_id: User ID requesting the revocation (must own the token or be admin) - + Returns: dict with 'success' and 'message' keys """ api_token = ApiToken.query.get(token_id) if not api_token: - return { - 'success': False, - 'message': 'Token not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Token not found", "error": "not_found"} + # Check permissions user = User.query.get(user_id) if not user or (not user.is_admin and api_token.user_id != user_id): return { - 'success': False, - 'message': 'You do not have permission to revoke this token', - 'error': 'permission_denied' + "success": False, + "message": "You do not have permission to revoke this token", + "error": "permission_denied", } - + # Deactivate token api_token.is_active = False - - if not safe_commit('revoke_api_token', {'token_id': token_id, 'user_id': user_id}): + + if not safe_commit("revoke_api_token", {"token_id": token_id, "user_id": user_id}): return { - 'success': False, - 'message': 'Could not revoke token due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not revoke token due to a database error", + "error": "database_error", } - + # Emit event - emit_event(WebhookEvent.API_TOKEN_REVOKED.value, { - 'token_id': token_id, - 'user_id': user_id - }) - - return { - 'success': True, - 'message': 'Token revoked successfully' - } - + emit_event(WebhookEvent.API_TOKEN_REVOKED.value, {"token_id": token_id, "user_id": user_id}) + + return {"success": True, "message": "Token revoked successfully"} + def get_expiring_tokens(self, days_ahead: int = 7) -> List[ApiToken]: """ Get tokens that will expire within the specified number of days. - + Args: days_ahead: Number of days to look ahead - + Returns: List of tokens expiring soon """ expiration_threshold = datetime.utcnow() + timedelta(days=days_ahead) - + return ApiToken.query.filter( ApiToken.is_active == True, ApiToken.expires_at.isnot(None), ApiToken.expires_at <= expiration_threshold, - ApiToken.expires_at > datetime.utcnow() + ApiToken.expires_at > datetime.utcnow(), ).all() - + def validate_scopes(self, scopes: str) -> Dict[str, Any]: """ Validate scope strings. - + Args: scopes: Comma-separated list of scopes - + Returns: dict with 'valid' bool and 'invalid' list of invalid scopes """ # Valid scope patterns valid_patterns = [ - 'read:*', - 'write:*', - 'admin:*', - 'read:projects', - 'read:time_entries', - 'read:invoices', - 'read:clients', - 'read:tasks', - 'read:reports', - 'write:projects', - 'write:time_entries', - 'write:invoices', - 'write:clients', - 'write:tasks', - 'admin:all', - '*' + "read:*", + "write:*", + "admin:*", + "read:projects", + "read:time_entries", + "read:invoices", + "read:clients", + "read:tasks", + "read:reports", + "write:projects", + "write:time_entries", + "write:invoices", + "write:clients", + "write:tasks", + "admin:all", + "*", ] - - scope_list = [s.strip() for s in scopes.split(',') if s.strip()] + + scope_list = [s.strip() for s in scopes.split(",") if s.strip()] invalid = [] - + for scope in scope_list: if scope not in valid_patterns: invalid.append(scope) - - return { - 'valid': len(invalid) == 0, - 'invalid': invalid - } - + + return {"valid": len(invalid) == 0, "invalid": invalid} + def check_token_rate_limit(self, token_id: int, max_requests_per_hour: int = 1000) -> Dict[str, Any]: """ Check if token has exceeded rate limit. This is a simple implementation - for production, use Redis or similar. - + Args: token_id: The token ID max_requests_per_hour: Maximum requests per hour - + Returns: dict with 'allowed' bool and 'remaining' requests """ @@ -318,17 +287,8 @@ def check_token_rate_limit(self, token_id: int, max_requests_per_hour: int = 100 # using Redis or similar distributed cache api_token = ApiToken.query.get(token_id) if not api_token: - return { - 'allowed': False, - 'remaining': 0, - 'error': 'token_not_found' - } - + return {"allowed": False, "remaining": 0, "error": "token_not_found"} + # Simple check: if usage_count is very high, might be rate limited # In production, track requests per hour in Redis - return { - 'allowed': True, - 'remaining': max_requests_per_hour, - 'reset_at': datetime.utcnow() + timedelta(hours=1) - } - + return {"allowed": True, "remaining": max_requests_per_hour, "reset_at": datetime.utcnow() + timedelta(hours=1)} diff --git a/app/services/backup_service.py b/app/services/backup_service.py index 942a1cfd..aac45bd0 100644 --- a/app/services/backup_service.py +++ b/app/services/backup_service.py @@ -14,152 +14,129 @@ class BackupService: """Service for backup operations""" - + def __init__(self): - self.backup_dir = os.path.join( - current_app.config.get('UPLOAD_FOLDER', '/data'), - 'backups' - ) + self.backup_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data"), "backups") os.makedirs(self.backup_dir, exist_ok=True) - - def create_database_backup( - self, - backup_name: Optional[str] = None - ) -> Dict[str, Any]: + + def create_database_backup(self, backup_name: Optional[str] = None) -> Dict[str, Any]: """ Create a database backup. - + Returns: dict with 'success', 'message', and 'backup_path' keys """ try: # Generate backup filename if not backup_name: - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_name = f"timetracker_backup_{timestamp}.sql" - + backup_path = os.path.join(self.backup_dir, backup_name) - + # Get database URL - db_url = current_app.config.get('SQLALCHEMY_DATABASE_URI', '') - + db_url = current_app.config.get("SQLALCHEMY_DATABASE_URI", "") + # PostgreSQL backup using pg_dump - if 'postgresql' in db_url: + if "postgresql" in db_url: import subprocess from urllib.parse import urlparse - - parsed = urlparse(db_url.replace('postgresql+psycopg2://', 'postgresql://')) - + + parsed = urlparse(db_url.replace("postgresql+psycopg2://", "postgresql://")) + cmd = [ - 'pg_dump', - '-h', parsed.hostname or 'localhost', - '-p', str(parsed.port or 5432), - '-U', parsed.username or 'timetracker', - '-d', parsed.path.lstrip('/') or 'timetracker', - '-f', backup_path, - '--no-password' # Use .pgpass file + "pg_dump", + "-h", + parsed.hostname or "localhost", + "-p", + str(parsed.port or 5432), + "-U", + parsed.username or "timetracker", + "-d", + parsed.path.lstrip("/") or "timetracker", + "-f", + backup_path, + "--no-password", # Use .pgpass file ] - + # Set password via environment env = os.environ.copy() if parsed.password: - env['PGPASSWORD'] = parsed.password - + env["PGPASSWORD"] = parsed.password + result = subprocess.run(cmd, env=env, capture_output=True, text=True) - + if result.returncode != 0: - return { - 'success': False, - 'message': f'Backup failed: {result.stderr}', - 'error': 'backup_failed' - } - + return {"success": False, "message": f"Backup failed: {result.stderr}", "error": "backup_failed"} + # SQLite backup - elif 'sqlite' in db_url: - db_path = db_url.replace('sqlite:///', '') + elif "sqlite" in db_url: + db_path = db_url.replace("sqlite:///", "") shutil.copy2(db_path, backup_path) - + else: - return { - 'success': False, - 'message': 'Unsupported database type', - 'error': 'unsupported_db' - } - + return {"success": False, "message": "Unsupported database type", "error": "unsupported_db"} + # Get backup size backup_size = os.path.getsize(backup_path) - + return { - 'success': True, - 'message': 'Backup created successfully', - 'backup_path': backup_path, - 'backup_size': backup_size, - 'backup_name': backup_name + "success": True, + "message": "Backup created successfully", + "backup_path": backup_path, + "backup_size": backup_size, + "backup_name": backup_name, } - + except Exception as e: current_app.logger.error(f"Backup failed: {e}") - return { - 'success': False, - 'message': f'Backup failed: {str(e)}', - 'error': 'backup_error' - } - + return {"success": False, "message": f"Backup failed: {str(e)}", "error": "backup_error"} + def list_backups(self) -> List[Dict[str, Any]]: """ List all available backups. - + Returns: List of backup information dicts """ backups = [] - + if not os.path.exists(self.backup_dir): return backups - + for filename in os.listdir(self.backup_dir): - if filename.endswith('.sql') or filename.endswith('.db'): + if filename.endswith(".sql") or filename.endswith(".db"): filepath = os.path.join(self.backup_dir, filename) stat = os.stat(filepath) - - backups.append({ - 'name': filename, - 'path': filepath, - 'size': stat.st_size, - 'created': datetime.fromtimestamp(stat.st_mtime).isoformat() - }) - + + backups.append( + { + "name": filename, + "path": filepath, + "size": stat.st_size, + "created": datetime.fromtimestamp(stat.st_mtime).isoformat(), + } + ) + # Sort by creation time (newest first) - backups.sort(key=lambda x: x['created'], reverse=True) - + backups.sort(key=lambda x: x["created"], reverse=True) + return backups - + def delete_backup(self, backup_name: str) -> Dict[str, Any]: """ Delete a backup file. - + Returns: dict with 'success' and 'message' keys """ backup_path = os.path.join(self.backup_dir, backup_name) - + if not os.path.exists(backup_path): - return { - 'success': False, - 'message': 'Backup not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Backup not found", "error": "not_found"} + try: os.remove(backup_path) - return { - 'success': True, - 'message': 'Backup deleted successfully' - } + return {"success": True, "message": "Backup deleted successfully"} except Exception as e: - return { - 'success': False, - 'message': f'Failed to delete backup: {str(e)}', - 'error': 'delete_error' - } - + return {"success": False, "message": f"Failed to delete backup: {str(e)}", "error": "delete_error"} diff --git a/app/services/base_crud_service.py b/app/services/base_crud_service.py index 36ca1720..bb526bb9 100644 --- a/app/services/base_crud_service.py +++ b/app/services/base_crud_service.py @@ -8,211 +8,173 @@ from app.utils.db import safe_commit from app.utils.api_responses import error_response -ModelType = TypeVar('ModelType') -RepositoryType = TypeVar('RepositoryType') +ModelType = TypeVar("ModelType") +RepositoryType = TypeVar("RepositoryType") class BaseCRUDService(Generic[ModelType, RepositoryType]): """ Base service class providing common CRUD operations. - + Subclasses should set: - self.repository: The repository instance - self.model_name: Human-readable model name for error messages """ - + def __init__(self, repository: RepositoryType, model_name: str = "Record"): """ Initialize base CRUD service. - + Args: repository: Repository instance for data access model_name: Human-readable name for error messages """ self.repository = repository self.model_name = model_name - + def get_by_id(self, record_id: int) -> Dict[str, Any]: """ Get a record by ID. - + Args: record_id: The record ID - + Returns: dict with 'success', 'message', and record data """ record = self.repository.get_by_id(record_id) - + if not record: - return { - 'success': False, - 'message': f'{self.model_name} not found', - 'error': 'not_found' - } - - return { - 'success': True, - 'message': f'{self.model_name} retrieved successfully', - 'data': record - } - + return {"success": False, "message": f"{self.model_name} not found", "error": "not_found"} + + return {"success": True, "message": f"{self.model_name} retrieved successfully", "data": record} + def create(self, **kwargs) -> Dict[str, Any]: """ Create a new record. - + Args: **kwargs: Fields for the new record - + Returns: dict with 'success', 'message', and created record """ try: record = self.repository.create(**kwargs) - - if not safe_commit(f'create_{self.model_name.lower()}', kwargs): + + if not safe_commit(f"create_{self.model_name.lower()}", kwargs): return { - 'success': False, - 'message': f'Could not create {self.model_name.lower()} due to a database error', - 'error': 'database_error' + "success": False, + "message": f"Could not create {self.model_name.lower()} due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': f'{self.model_name} created successfully', - 'data': record - } + + return {"success": True, "message": f"{self.model_name} created successfully", "data": record} except Exception as e: return { - 'success': False, - 'message': f'Error creating {self.model_name.lower()}: {str(e)}', - 'error': 'creation_error' + "success": False, + "message": f"Error creating {self.model_name.lower()}: {str(e)}", + "error": "creation_error", } - + def update(self, record_id: int, **kwargs) -> Dict[str, Any]: """ Update an existing record. - + Args: record_id: The record ID **kwargs: Fields to update - + Returns: dict with 'success', 'message', and updated record """ record = self.repository.get_by_id(record_id) - + if not record: - return { - 'success': False, - 'message': f'{self.model_name} not found', - 'error': 'not_found' - } - + return {"success": False, "message": f"{self.model_name} not found", "error": "not_found"} + try: self.repository.update(record, **kwargs) - - if not safe_commit(f'update_{self.model_name.lower()}', {'record_id': record_id}): + + if not safe_commit(f"update_{self.model_name.lower()}", {"record_id": record_id}): return { - 'success': False, - 'message': f'Could not update {self.model_name.lower()} due to a database error', - 'error': 'database_error' + "success": False, + "message": f"Could not update {self.model_name.lower()} due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': f'{self.model_name} updated successfully', - 'data': record - } + + return {"success": True, "message": f"{self.model_name} updated successfully", "data": record} except Exception as e: return { - 'success': False, - 'message': f'Error updating {self.model_name.lower()}: {str(e)}', - 'error': 'update_error' + "success": False, + "message": f"Error updating {self.model_name.lower()}: {str(e)}", + "error": "update_error", } - + def delete(self, record_id: int) -> Dict[str, Any]: """ Delete a record. - + Args: record_id: The record ID - + Returns: dict with 'success' and 'message' """ record = self.repository.get_by_id(record_id) - + if not record: - return { - 'success': False, - 'message': f'{self.model_name} not found', - 'error': 'not_found' - } - + return {"success": False, "message": f"{self.model_name} not found", "error": "not_found"} + try: if not self.repository.delete(record): return { - 'success': False, - 'message': f'Could not delete {self.model_name.lower()}', - 'error': 'delete_error' + "success": False, + "message": f"Could not delete {self.model_name.lower()}", + "error": "delete_error", } - - if not safe_commit(f'delete_{self.model_name.lower()}', {'record_id': record_id}): + + if not safe_commit(f"delete_{self.model_name.lower()}", {"record_id": record_id}): return { - 'success': False, - 'message': f'Could not delete {self.model_name.lower()} due to a database error', - 'error': 'database_error' + "success": False, + "message": f"Could not delete {self.model_name.lower()} due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': f'{self.model_name} deleted successfully' - } + + return {"success": True, "message": f"{self.model_name} deleted successfully"} except Exception as e: return { - 'success': False, - 'message': f'Error deleting {self.model_name.lower()}: {str(e)}', - 'error': 'delete_error' + "success": False, + "message": f"Error deleting {self.model_name.lower()}: {str(e)}", + "error": "delete_error", } - - def list_all( - self, - page: int = 1, - per_page: int = 20, - **filters - ) -> Dict[str, Any]: + + def list_all(self, page: int = 1, per_page: int = 20, **filters) -> Dict[str, Any]: """ List all records with pagination and optional filters. - + Args: page: Page number per_page: Records per page **filters: Filter criteria - + Returns: dict with 'success', 'data', 'pagination', and 'total' """ try: query = self.repository.query() - + # Apply filters if filters: query = query.filter_by(**filters) - + # Paginate pagination = query.paginate(page=page, per_page=per_page, error_out=False) - - return { - 'success': True, - 'data': pagination.items, - 'pagination': pagination, - 'total': pagination.total - } + + return {"success": True, "data": pagination.items, "pagination": pagination, "total": pagination.total} except Exception as e: return { - 'success': False, - 'message': f'Error listing {self.model_name.lower()}: {str(e)}', - 'error': 'list_error' + "success": False, + "message": f"Error listing {self.model_name.lower()}: {str(e)}", + "error": "list_error", } - diff --git a/app/services/calendar_integration_service.py b/app/services/calendar_integration_service.py index 458c5c69..71b43311 100644 --- a/app/services/calendar_integration_service.py +++ b/app/services/calendar_integration_service.py @@ -18,7 +18,7 @@ class CalendarIntegrationService: """ Service for calendar integration operations. """ - + def create_integration( self, user_id: int, @@ -28,22 +28,18 @@ def create_integration( token_expires_at: Optional[Any] = None, calendar_id: Optional[str] = None, calendar_name: Optional[str] = None, - sync_settings: Optional[Dict[str, Any]] = None + sync_settings: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Create a calendar integration. - + Returns: dict with 'success', 'message', and 'integration' keys """ try: # Check if integration already exists for this user and provider - existing = CalendarIntegration.query.filter_by( - user_id=user_id, - provider=provider, - is_active=True - ).first() - + existing = CalendarIntegration.query.filter_by(user_id=user_id, provider=provider, is_active=True).first() + if existing: # Update existing integration existing.access_token = access_token @@ -53,19 +49,16 @@ def create_integration( existing.calendar_name = calendar_name if sync_settings: existing.sync_settings = sync_settings - - if not safe_commit('update_calendar_integration', {'integration_id': existing.id}): - return { - 'success': False, - 'message': 'Could not update integration due to a database error.' - } - + + if not safe_commit("update_calendar_integration", {"integration_id": existing.id}): + return {"success": False, "message": "Could not update integration due to a database error."} + return { - 'success': True, - 'message': 'Calendar integration updated successfully.', - 'integration': existing + "success": True, + "message": "Calendar integration updated successfully.", + "integration": existing, } - + integration = CalendarIntegration( user_id=user_id, provider=provider, @@ -75,204 +68,145 @@ def create_integration( calendar_id=calendar_id, calendar_name=calendar_name, sync_settings=sync_settings or {}, - is_active=True + is_active=True, ) - + db.session.add(integration) - if not safe_commit('create_calendar_integration', {'user_id': user_id, 'provider': provider}): - return { - 'success': False, - 'message': 'Could not create integration due to a database error.' - } - + if not safe_commit("create_calendar_integration", {"user_id": user_id, "provider": provider}): + return {"success": False, "message": "Could not create integration due to a database error."} + return { - 'success': True, - 'message': 'Calendar integration created successfully.', - 'integration': integration + "success": True, + "message": "Calendar integration created successfully.", + "integration": integration, } except Exception as e: db.session.rollback() logger.error(f"Error creating calendar integration: {e}") - return { - 'success': False, - 'message': f'Error creating integration: {str(e)}' - } - + return {"success": False, "message": f"Error creating integration: {str(e)}"} + def get_integration(self, integration_id: int) -> Optional[CalendarIntegration]: """Get an integration by ID""" return CalendarIntegration.query.get(integration_id) - - def get_user_integrations( - self, - user_id: int, - provider: Optional[str] = None - ) -> List[CalendarIntegration]: + + def get_user_integrations(self, user_id: int, provider: Optional[str] = None) -> List[CalendarIntegration]: """Get all integrations for a user""" query = CalendarIntegration.query.filter_by(user_id=user_id, is_active=True) if provider: query = query.filter_by(provider=provider) return query.all() - + def sync_time_entry_to_calendar( - self, - integration_id: int, - time_entry_id: int, - calendar_event_id: Optional[str] = None + self, integration_id: int, time_entry_id: int, calendar_event_id: Optional[str] = None ) -> Dict[str, Any]: """ Sync a time entry to calendar. - + Returns: dict with 'success', 'message', and 'sync_event' keys """ try: integration = CalendarIntegration.query.get(integration_id) if not integration or not integration.is_active: - return { - 'success': False, - 'message': 'Integration not found or inactive.' - } - + return {"success": False, "message": "Integration not found or inactive."} + time_entry = TimeEntry.query.get(time_entry_id) if not time_entry: - return { - 'success': False, - 'message': 'Time entry not found.' - } - + return {"success": False, "message": "Time entry not found."} + # Create sync event sync_event = CalendarSyncEvent( integration_id=integration_id, - event_type='time_entry_created', + event_type="time_entry_created", time_entry_id=time_entry_id, calendar_event_id=calendar_event_id, - direction='to_calendar', - status='pending' + direction="to_calendar", + status="pending", ) - + db.session.add(sync_event) - if not safe_commit('sync_time_entry', {'time_entry_id': time_entry_id}): - return { - 'success': False, - 'message': 'Could not create sync event due to a database error.' - } - - return { - 'success': True, - 'message': 'Sync event created successfully.', - 'sync_event': sync_event - } + if not safe_commit("sync_time_entry", {"time_entry_id": time_entry_id}): + return {"success": False, "message": "Could not create sync event due to a database error."} + + return {"success": True, "message": "Sync event created successfully.", "sync_event": sync_event} except Exception as e: db.session.rollback() logger.error(f"Error syncing time entry: {e}") - return { - 'success': False, - 'message': f'Error syncing time entry: {str(e)}' - } - + return {"success": False, "message": f"Error syncing time entry: {str(e)}"} + def update_sync_status( self, sync_event_id: int, status: str, calendar_event_id: Optional[str] = None, - error_message: Optional[str] = None + error_message: Optional[str] = None, ) -> Dict[str, Any]: """ Update sync event status. - + Returns: dict with 'success', 'message', and 'sync_event' keys """ try: sync_event = CalendarSyncEvent.query.get(sync_event_id) if not sync_event: - return { - 'success': False, - 'message': 'Sync event not found.' - } - + return {"success": False, "message": "Sync event not found."} + sync_event.status = status sync_event.synced_at = now_in_app_timezone() - + if calendar_event_id: sync_event.calendar_event_id = calendar_event_id if error_message: sync_event.error_message = error_message - + # Update integration last sync integration = sync_event.integration integration.last_sync_at = now_in_app_timezone() integration.last_sync_status = status if error_message: integration.last_sync_error = error_message - - if not safe_commit('update_sync_status', {'sync_event_id': sync_event_id}): - return { - 'success': False, - 'message': 'Could not update sync status due to a database error.' - } - - if status == 'synced': - emit_event(WebhookEvent.CALENDAR_SYNCED, { - 'integration_id': integration.id, - 'sync_event_id': sync_event.id, - 'time_entry_id': sync_event.time_entry_id - }) - - return { - 'success': True, - 'message': 'Sync status updated successfully.', - 'sync_event': sync_event - } + + if not safe_commit("update_sync_status", {"sync_event_id": sync_event_id}): + return {"success": False, "message": "Could not update sync status due to a database error."} + + if status == "synced": + emit_event( + WebhookEvent.CALENDAR_SYNCED, + { + "integration_id": integration.id, + "sync_event_id": sync_event.id, + "time_entry_id": sync_event.time_entry_id, + }, + ) + + return {"success": True, "message": "Sync status updated successfully.", "sync_event": sync_event} except Exception as e: db.session.rollback() logger.error(f"Error updating sync status: {e}") - return { - 'success': False, - 'message': f'Error updating sync status: {str(e)}' - } - - def deactivate_integration( - self, - integration_id: int, - user_id: int - ) -> Dict[str, Any]: + return {"success": False, "message": f"Error updating sync status: {str(e)}"} + + def deactivate_integration(self, integration_id: int, user_id: int) -> Dict[str, Any]: """ Deactivate a calendar integration. - + Returns: dict with 'success' and 'message' keys """ try: integration = CalendarIntegration.query.get(integration_id) if not integration: - return { - 'success': False, - 'message': 'Integration not found.' - } - + return {"success": False, "message": "Integration not found."} + if integration.user_id != user_id: - return { - 'success': False, - 'message': 'You do not have permission to deactivate this integration.' - } - + return {"success": False, "message": "You do not have permission to deactivate this integration."} + integration.is_active = False - if not safe_commit('deactivate_integration', {'integration_id': integration_id}): - return { - 'success': False, - 'message': 'Could not deactivate integration due to a database error.' - } - - return { - 'success': True, - 'message': 'Integration deactivated successfully.' - } + if not safe_commit("deactivate_integration", {"integration_id": integration_id}): + return {"success": False, "message": "Could not deactivate integration due to a database error."} + + return {"success": True, "message": "Integration deactivated successfully."} except Exception as e: db.session.rollback() logger.error(f"Error deactivating integration: {e}") - return { - 'success': False, - 'message': f'Error deactivating integration: {str(e)}' - } - + return {"success": False, "message": f"Error deactivating integration: {str(e)}"} diff --git a/app/services/client_service.py b/app/services/client_service.py index 7e2ad921..ed064db1 100644 --- a/app/services/client_service.py +++ b/app/services/client_service.py @@ -12,10 +12,10 @@ class ClientService: """Service for client operations""" - + def __init__(self): self.client_repo = ClientRepository() - + def create_client( self, name: str, @@ -24,23 +24,19 @@ def create_client( company: Optional[str] = None, phone: Optional[str] = None, address: Optional[str] = None, - default_hourly_rate: Optional[Decimal] = None + default_hourly_rate: Optional[Decimal] = None, ) -> Dict[str, Any]: """ Create a new client. - + Returns: dict with 'success', 'message', and 'client' keys """ # Check for duplicate name existing = self.client_repo.get_by_name(name) if existing: - return { - 'success': False, - 'message': 'A client with this name already exists', - 'error': 'duplicate_client' - } - + return {"success": False, "message": "A client with this name already exists", "error": "duplicate_client"} + # Create client client = self.client_repo.create( name=name, @@ -49,60 +45,42 @@ def create_client( phone=phone, address=address, default_hourly_rate=default_hourly_rate, - status='active' + status="active", ) - - if not safe_commit('create_client', {'name': name, 'created_by': created_by}): + + if not safe_commit("create_client", {"name": name, "created_by": created_by}): return { - 'success': False, - 'message': 'Could not create client due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create client due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Client created successfully', - 'client': client - } - - def update_client( - self, - client_id: int, - user_id: int, - **kwargs - ) -> Dict[str, Any]: + + return {"success": True, "message": "Client created successfully", "client": client} + + def update_client(self, client_id: int, user_id: int, **kwargs) -> Dict[str, Any]: """ Update a client. - + Returns: dict with 'success', 'message', and 'client' keys """ client = self.client_repo.get_by_id(client_id) - + if not client: - return { - 'success': False, - 'message': 'Client not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Client not found", "error": "not_found"} + # Update fields self.client_repo.update(client, **kwargs) - - if not safe_commit('update_client', {'client_id': client_id, 'user_id': user_id}): + + if not safe_commit("update_client", {"client_id": client_id, "user_id": user_id}): return { - 'success': False, - 'message': 'Could not update client due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not update client due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Client updated successfully', - 'client': client - } - + + return {"success": True, "message": "Client updated successfully", "client": client} + def get_active_clients(self) -> List[Client]: """Get all active clients""" return self.client_repo.get_active_clients() - diff --git a/app/services/comment_service.py b/app/services/comment_service.py index eb239be2..74b29a93 100644 --- a/app/services/comment_service.py +++ b/app/services/comment_service.py @@ -12,12 +12,12 @@ class CommentService: """Service for comment operations""" - + def __init__(self): self.comment_repo = CommentRepository() self.project_repo = ProjectRepository() self.task_repo = TaskRepository() - + def create_comment( self, content: str, @@ -26,75 +26,57 @@ def create_comment( task_id: Optional[int] = None, quote_id: Optional[int] = None, parent_id: Optional[int] = None, - is_internal: bool = True + is_internal: bool = True, ) -> Dict[str, Any]: """ Create a new comment. - + Returns: dict with 'success', 'message', and 'comment' keys """ # Validate content if not content or not content.strip(): - return { - 'success': False, - 'message': 'Comment content cannot be empty', - 'error': 'empty_content' - } - + return {"success": False, "message": "Comment content cannot be empty", "error": "empty_content"} + # Validate target targets = [x for x in [project_id, task_id, quote_id] if x is not None] if len(targets) == 0: return { - 'success': False, - 'message': 'Comment must be associated with a project, task, or quote', - 'error': 'no_target' + "success": False, + "message": "Comment must be associated with a project, task, or quote", + "error": "no_target", } - + if len(targets) > 1: return { - 'success': False, - 'message': 'Comment cannot be associated with multiple targets', - 'error': 'multiple_targets' + "success": False, + "message": "Comment cannot be associated with multiple targets", + "error": "multiple_targets", } - + # Validate target exists if project_id: project = self.project_repo.get_by_id(project_id) if not project: - return { - 'success': False, - 'message': 'Project not found', - 'error': 'invalid_project' - } + return {"success": False, "message": "Project not found", "error": "invalid_project"} elif task_id: task = self.task_repo.get_by_id(task_id) if not task: - return { - 'success': False, - 'message': 'Task not found', - 'error': 'invalid_task' - } - + return {"success": False, "message": "Task not found", "error": "invalid_task"} + # Validate parent comment if reply if parent_id: parent = self.comment_repo.get_by_id(parent_id) if not parent: - return { - 'success': False, - 'message': 'Parent comment not found', - 'error': 'invalid_parent' - } + return {"success": False, "message": "Parent comment not found", "error": "invalid_parent"} # Verify parent is for same target - if (project_id and parent.project_id != project_id) or \ - (task_id and parent.task_id != task_id) or \ - (quote_id and parent.quote_id != quote_id): - return { - 'success': False, - 'message': 'Invalid parent comment', - 'error': 'invalid_parent_target' - } - + if ( + (project_id and parent.project_id != project_id) + or (task_id and parent.task_id != task_id) + or (quote_id and parent.quote_id != quote_id) + ): + return {"success": False, "message": "Invalid parent comment", "error": "invalid_parent_target"} + # Create comment comment = self.comment_repo.create( content=content.strip(), @@ -103,94 +85,64 @@ def create_comment( task_id=task_id, quote_id=quote_id, parent_id=parent_id, - is_internal=is_internal + is_internal=is_internal, ) - - if not safe_commit('create_comment', {'user_id': user_id}): + + if not safe_commit("create_comment", {"user_id": user_id}): return { - 'success': False, - 'message': 'Could not create comment due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create comment due to a database error", + "error": "database_error", } - + # Emit domain event - emit_event('comment.created', { - 'comment_id': comment.id, - 'user_id': user_id, - 'project_id': project_id, - 'task_id': task_id, - 'quote_id': quote_id - }) - - return { - 'success': True, - 'message': 'Comment created successfully', - 'comment': comment - } - - def get_project_comments( - self, - project_id: int, - include_replies: bool = True - ) -> List[Comment]: + emit_event( + "comment.created", + { + "comment_id": comment.id, + "user_id": user_id, + "project_id": project_id, + "task_id": task_id, + "quote_id": quote_id, + }, + ) + + return {"success": True, "message": "Comment created successfully", "comment": comment} + + def get_project_comments(self, project_id: int, include_replies: bool = True) -> List[Comment]: """Get comments for a project""" return self.comment_repo.get_by_project( - project_id=project_id, - include_replies=include_replies, - include_relations=True + project_id=project_id, include_replies=include_replies, include_relations=True ) - - def get_task_comments( - self, - task_id: int, - include_replies: bool = True - ) -> List[Comment]: + + def get_task_comments(self, task_id: int, include_replies: bool = True) -> List[Comment]: """Get comments for a task""" - return self.comment_repo.get_by_task( - task_id=task_id, - include_replies=include_replies, - include_relations=True - ) - - def delete_comment( - self, - comment_id: int, - user_id: int - ) -> Dict[str, Any]: + return self.comment_repo.get_by_task(task_id=task_id, include_replies=include_replies, include_relations=True) + + def delete_comment(self, comment_id: int, user_id: int) -> Dict[str, Any]: """ Delete a comment. - + Returns: dict with 'success' and 'message' keys """ comment = self.comment_repo.get_by_id(comment_id) - + if not comment: - return { - 'success': False, - 'message': 'Comment not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Comment not found", "error": "not_found"} + # Check permissions (user can only delete their own comments unless admin) from flask_login import current_user - if comment.user_id != user_id and not (hasattr(current_user, 'is_admin') and current_user.is_admin): + + if comment.user_id != user_id and not (hasattr(current_user, "is_admin") and current_user.is_admin): return { - 'success': False, - 'message': 'You do not have permission to delete this comment', - 'error': 'unauthorized' + "success": False, + "message": "You do not have permission to delete this comment", + "error": "unauthorized", } - + if self.comment_repo.delete(comment): - if safe_commit('delete_comment', {'comment_id': comment_id, 'user_id': user_id}): - return { - 'success': True, - 'message': 'Comment deleted successfully' - } - - return { - 'success': False, - 'message': 'Could not delete comment', - 'error': 'database_error' - } + if safe_commit("delete_comment", {"comment_id": comment_id, "user_id": user_id}): + return {"success": True, "message": "Comment deleted successfully"} + return {"success": False, "message": "Could not delete comment", "error": "database_error"} diff --git a/app/services/email_service.py b/app/services/email_service.py index 9ccaa1fc..3ad82ba1 100644 --- a/app/services/email_service.py +++ b/app/services/email_service.py @@ -11,44 +11,36 @@ class EmailService: """Service for email operations""" - + def __init__(self): self.invoice_repo = InvoiceRepository() - + def send_invoice_email( self, invoice_id: int, recipient_email: str, subject: Optional[str] = None, message: Optional[str] = None, - attach_pdf: bool = True + attach_pdf: bool = True, ) -> Dict[str, Any]: """ Send an invoice via email. - + Returns: dict with 'success' and 'message' keys """ invoice = self.invoice_repo.get_with_relations(invoice_id) - + if not invoice: - return { - 'success': False, - 'message': 'Invoice not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Invoice not found", "error": "not_found"} + # Generate subject if not provided if not subject: subject = f"Invoice {invoice.invoice_number} from {current_app.config.get('COMPANY_NAME', 'TimeTracker')}" - + # Render email template try: - html_body = render_template( - 'email/invoice.html', - invoice=invoice, - message=message - ) + html_body = render_template("email/invoice.html", invoice=invoice, message=message) except Exception: # Fallback to simple text html_body = f""" @@ -59,7 +51,7 @@ def send_invoice_email( """ if message: html_body += f"

{message}

" - + # Send email try: send_email( @@ -67,36 +59,29 @@ def send_invoice_email( recipients=[recipient_email], text_body=message or f"Invoice {invoice.invoice_number}", html_body=html_body, - attachments=[] # PDF attachment would be added here + attachments=[], # PDF attachment would be added here ) - + # Mark invoice as sent self.invoice_repo.mark_as_sent(invoice_id) - - return { - 'success': True, - 'message': 'Invoice email sent successfully' - } - + + return {"success": True, "message": "Invoice email sent successfully"} + except Exception as e: current_app.logger.error(f"Failed to send invoice email: {e}") - return { - 'success': False, - 'message': f'Failed to send email: {str(e)}', - 'error': 'email_error' - } - + return {"success": False, "message": f"Failed to send email: {str(e)}", "error": "email_error"} + def send_notification_email( self, recipient_email: str, subject: str, message: str, template: Optional[str] = None, - context: Optional[Dict[str, Any]] = None + context: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Send a notification email. - + Returns: dict with 'success' and 'message' keys """ @@ -105,24 +90,11 @@ def send_notification_email( html_body = render_template(template, **(context or {})) else: html_body = f"

{message}

" - - send_email( - subject=subject, - recipients=[recipient_email], - text_body=message, - html_body=html_body - ) - - return { - 'success': True, - 'message': 'Notification email sent successfully' - } - + + send_email(subject=subject, recipients=[recipient_email], text_body=message, html_body=html_body) + + return {"success": True, "message": "Notification email sent successfully"} + except Exception as e: current_app.logger.error(f"Failed to send notification email: {e}") - return { - 'success': False, - 'message': f'Failed to send email: {str(e)}', - 'error': 'email_error' - } - + return {"success": False, "message": f"Failed to send email: {str(e)}", "error": "email_error"} diff --git a/app/services/expense_service.py b/app/services/expense_service.py index 4ecdd26b..b3b96c40 100644 --- a/app/services/expense_service.py +++ b/app/services/expense_service.py @@ -13,11 +13,11 @@ class ExpenseService: """Service for expense operations""" - + def __init__(self): self.expense_repo = ExpenseRepository() self.project_repo = ProjectRepository() - + def create_expense( self, project_id: int, @@ -27,31 +27,23 @@ def create_expense( created_by: int, category_id: Optional[int] = None, billable: bool = False, - receipt_path: Optional[str] = None + receipt_path: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new expense. - + Returns: dict with 'success', 'message', and 'expense' keys """ # Validate project project = self.project_repo.get_by_id(project_id) if not project: - return { - 'success': False, - 'message': 'Invalid project', - 'error': 'invalid_project' - } - + return {"success": False, "message": "Invalid project", "error": "invalid_project"} + # Validate amount if amount <= 0: - return { - 'success': False, - 'message': 'Amount must be greater than zero', - 'error': 'invalid_amount' - } - + return {"success": False, "message": "Amount must be greater than zero", "error": "invalid_amount"} + # Create expense expense = self.expense_repo.create( project_id=project_id, @@ -61,48 +53,34 @@ def create_expense( category_id=category_id, billable=billable, receipt_path=receipt_path, - created_by=created_by + created_by=created_by, ) - - if not safe_commit('create_expense', {'project_id': project_id, 'created_by': created_by}): + + if not safe_commit("create_expense", {"project_id": project_id, "created_by": created_by}): return { - 'success': False, - 'message': 'Could not create expense due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create expense due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Expense created successfully', - 'expense': expense - } - + + return {"success": True, "message": "Expense created successfully", "expense": expense} + def get_project_expenses( - self, - project_id: int, - start_date: Optional[date] = None, - end_date: Optional[date] = None + self, project_id: int, start_date: Optional[date] = None, end_date: Optional[date] = None ) -> List[Expense]: """Get expenses for a project""" return self.expense_repo.get_by_project( - project_id=project_id, - start_date=start_date, - end_date=end_date, - include_relations=True + project_id=project_id, start_date=start_date, end_date=end_date, include_relations=True ) - + def get_total_expenses( self, project_id: Optional[int] = None, start_date: Optional[date] = None, end_date: Optional[date] = None, - billable_only: bool = False + billable_only: bool = False, ) -> float: """Get total expense amount""" return self.expense_repo.get_total_amount( - project_id=project_id, - start_date=start_date, - end_date=end_date, - billable_only=billable_only + project_id=project_id, start_date=start_date, end_date=end_date, billable_only=billable_only ) - diff --git a/app/services/export_service.py b/app/services/export_service.py index b9feecb4..93e0d2b2 100644 --- a/app/services/export_service.py +++ b/app/services/export_service.py @@ -6,145 +6,136 @@ from datetime import datetime, date from io import BytesIO import csv -from app.repositories import ( - TimeEntryRepository, - ProjectRepository, - InvoiceRepository, - ExpenseRepository -) +from app.repositories import TimeEntryRepository, ProjectRepository, InvoiceRepository, ExpenseRepository from app.models import TimeEntry, Project, Invoice, Expense class ExportService: """Service for export operations""" - + def __init__(self): self.time_entry_repo = TimeEntryRepository() self.project_repo = ProjectRepository() self.invoice_repo = InvoiceRepository() self.expense_repo = ExpenseRepository() - + def export_time_entries_csv( self, user_id: Optional[int] = None, project_id: Optional[int] = None, start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + end_date: Optional[datetime] = None, ) -> BytesIO: """ Export time entries to CSV. - + Returns: BytesIO object with CSV data """ # Get entries if start_date and end_date: entries = self.time_entry_repo.get_by_date_range( - start_date=start_date, - end_date=end_date, - user_id=user_id, - project_id=project_id, - include_relations=True + start_date=start_date, end_date=end_date, user_id=user_id, project_id=project_id, include_relations=True ) elif project_id: - entries = self.time_entry_repo.get_by_project( - project_id=project_id, - include_relations=True - ) + entries = self.time_entry_repo.get_by_project(project_id=project_id, include_relations=True) elif user_id: - entries = self.time_entry_repo.get_by_user( - user_id=user_id, - include_relations=True - ) + entries = self.time_entry_repo.get_by_user(user_id=user_id, include_relations=True) else: entries = [] - + # Create CSV output = BytesIO() writer = csv.writer(output) - + # Write header - writer.writerow([ - 'Date', 'User', 'Project', 'Task', 'Start Time', 'End Time', - 'Duration (hours)', 'Notes', 'Tags', 'Billable', 'Source' - ]) - + writer.writerow( + [ + "Date", + "User", + "Project", + "Task", + "Start Time", + "End Time", + "Duration (hours)", + "Notes", + "Tags", + "Billable", + "Source", + ] + ) + # Write rows for entry in entries: duration_hours = (entry.duration_seconds or 0) / 3600 - writer.writerow([ - entry.start_time.date().isoformat() if entry.start_time else '', - entry.user.username if entry.user else '', - entry.project.name if entry.project else '', - entry.task.name if entry.task else '', - entry.start_time.isoformat() if entry.start_time else '', - entry.end_time.isoformat() if entry.end_time else '', - f"{duration_hours:.2f}", - entry.notes or '', - entry.tags or '', - 'Yes' if entry.billable else 'No', - entry.source or '' - ]) - + writer.writerow( + [ + entry.start_time.date().isoformat() if entry.start_time else "", + entry.user.username if entry.user else "", + entry.project.name if entry.project else "", + entry.task.name if entry.task else "", + entry.start_time.isoformat() if entry.start_time else "", + entry.end_time.isoformat() if entry.end_time else "", + f"{duration_hours:.2f}", + entry.notes or "", + entry.tags or "", + "Yes" if entry.billable else "No", + entry.source or "", + ] + ) + output.seek(0) return output - - def export_projects_csv( - self, - status: Optional[str] = None, - client_id: Optional[int] = None - ) -> BytesIO: + + def export_projects_csv(self, status: Optional[str] = None, client_id: Optional[int] = None) -> BytesIO: """ Export projects to CSV. - + Returns: BytesIO object with CSV data """ # Get projects - if status == 'active': - projects = self.project_repo.get_active_projects( - client_id=client_id, - include_relations=True - ) + if status == "active": + projects = self.project_repo.get_active_projects(client_id=client_id, include_relations=True) else: - projects = self.project_repo.get_all() if not client_id else \ - self.project_repo.get_by_client(client_id, status=status, include_relations=True) - + projects = ( + self.project_repo.get_all() + if not client_id + else self.project_repo.get_by_client(client_id, status=status, include_relations=True) + ) + # Create CSV output = BytesIO() writer = csv.writer(output) - + # Write header - writer.writerow([ - 'Name', 'Client', 'Status', 'Billable', 'Hourly Rate', - 'Budget', 'Estimated Hours', 'Created', 'Updated' - ]) - + writer.writerow( + ["Name", "Client", "Status", "Billable", "Hourly Rate", "Budget", "Estimated Hours", "Created", "Updated"] + ) + # Write rows for project in projects: - writer.writerow([ - project.name, - project.client.name if project.client else '', - project.status, - 'Yes' if project.billable else 'No', - str(project.hourly_rate) if project.hourly_rate else '', - str(project.budget_amount) if project.budget_amount else '', - str(project.estimated_hours) if project.estimated_hours else '', - project.created_at.isoformat() if project.created_at else '', - project.updated_at.isoformat() if project.updated_at else '' - ]) - + writer.writerow( + [ + project.name, + project.client.name if project.client else "", + project.status, + "Yes" if project.billable else "No", + str(project.hourly_rate) if project.hourly_rate else "", + str(project.budget_amount) if project.budget_amount else "", + str(project.estimated_hours) if project.estimated_hours else "", + project.created_at.isoformat() if project.created_at else "", + project.updated_at.isoformat() if project.updated_at else "", + ] + ) + output.seek(0) return output - - def export_invoices_csv( - self, - status: Optional[str] = None, - client_id: Optional[int] = None - ) -> BytesIO: + + def export_invoices_csv(self, status: Optional[str] = None, client_id: Optional[int] = None) -> BytesIO: """ Export invoices to CSV. - + Returns: BytesIO object with CSV data """ @@ -155,34 +146,46 @@ def export_invoices_csv( invoices = self.invoice_repo.get_by_client(client_id, include_relations=True) else: invoices = self.invoice_repo.get_all() - + # Create CSV output = BytesIO() writer = csv.writer(output) - + # Write header - writer.writerow([ - 'Invoice Number', 'Client', 'Project', 'Issue Date', 'Due Date', - 'Status', 'Subtotal', 'Tax', 'Total', 'Amount Paid', 'Outstanding' - ]) - + writer.writerow( + [ + "Invoice Number", + "Client", + "Project", + "Issue Date", + "Due Date", + "Status", + "Subtotal", + "Tax", + "Total", + "Amount Paid", + "Outstanding", + ] + ) + # Write rows for invoice in invoices: outstanding = invoice.total_amount - (invoice.amount_paid or 0) - writer.writerow([ - invoice.invoice_number, - invoice.client_name, - invoice.project.name if invoice.project else '', - invoice.issue_date.isoformat() if invoice.issue_date else '', - invoice.due_date.isoformat() if invoice.due_date else '', - invoice.status, - str(invoice.subtotal), - str(invoice.tax_amount), - str(invoice.total_amount), - str(invoice.amount_paid or 0), - str(outstanding) - ]) - + writer.writerow( + [ + invoice.invoice_number, + invoice.client_name, + invoice.project.name if invoice.project else "", + invoice.issue_date.isoformat() if invoice.issue_date else "", + invoice.due_date.isoformat() if invoice.due_date else "", + invoice.status, + str(invoice.subtotal), + str(invoice.tax_amount), + str(invoice.total_amount), + str(invoice.amount_paid or 0), + str(outstanding), + ] + ) + output.seek(0) return output - diff --git a/app/services/health_service.py b/app/services/health_service.py index 28530ed7..814a08ed 100644 --- a/app/services/health_service.py +++ b/app/services/health_service.py @@ -11,63 +11,56 @@ class HealthService: """Service for health check operations""" - + def get_health_status(self) -> Dict[str, Any]: """ Get system health status. - + Returns: dict with health information """ status = { - 'status': 'healthy', - 'timestamp': datetime.now().isoformat(), - 'version': current_app.config.get('APP_VERSION', 'unknown'), - 'checks': {} + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "version": current_app.config.get("APP_VERSION", "unknown"), + "checks": {}, } - + # Database check try: - db.session.execute(text('SELECT 1')) - status['checks']['database'] = 'healthy' + db.session.execute(text("SELECT 1")) + status["checks"]["database"] = "healthy" except Exception as e: - status['checks']['database'] = f'unhealthy: {str(e)}' - status['status'] = 'unhealthy' - + status["checks"]["database"] = f"unhealthy: {str(e)}" + status["status"] = "unhealthy" + # Disk space check (if possible) try: import shutil - total, used, free = shutil.disk_usage('/') - status['checks']['disk'] = { - 'total_gb': round(total / (1024**3), 2), - 'used_gb': round(used / (1024**3), 2), - 'free_gb': round(free / (1024**3), 2), - 'free_percent': round((free / total) * 100, 2) + + total, used, free = shutil.disk_usage("/") + status["checks"]["disk"] = { + "total_gb": round(total / (1024**3), 2), + "used_gb": round(used / (1024**3), 2), + "free_gb": round(free / (1024**3), 2), + "free_percent": round((free / total) * 100, 2), } except Exception: - status['checks']['disk'] = 'unavailable' - + status["checks"]["disk"] = "unavailable" + return status - + def get_readiness_status(self) -> Dict[str, Any]: """ Get system readiness status (for Kubernetes readiness probe). - + Returns: dict with readiness information """ try: # Check database connectivity - db.session.execute(text('SELECT 1')) - - return { - 'ready': True, - 'timestamp': datetime.now().isoformat() - } - except Exception: - return { - 'ready': False, - 'timestamp': datetime.now().isoformat(), - 'error': 'Database not available' - } + db.session.execute(text("SELECT 1")) + return {"ready": True, "timestamp": datetime.now().isoformat()} + except Exception: + return {"ready": False, "timestamp": datetime.now().isoformat(), "error": "Database not available"} diff --git a/app/services/import_service.py b/app/services/import_service.py index bec73e1c..7025a9e6 100644 --- a/app/services/import_service.py +++ b/app/services/import_service.py @@ -14,48 +14,43 @@ class ImportService: """Service for import operations""" - + def __init__(self): self.time_tracking_service = TimeTrackingService() self.project_service = ProjectService() self.client_service = ClientService() self.project_repo = ProjectRepository() self.client_repo = ClientRepository() - - def import_time_entries_csv( - self, - file, - user_id: int, - default_project_id: Optional[int] = None - ) -> Dict[str, Any]: + + def import_time_entries_csv(self, file, user_id: int, default_project_id: Optional[int] = None) -> Dict[str, Any]: """ Import time entries from CSV. - + CSV format expected: Date, Project, Start Time, End Time, Notes, Tags, Billable - + Returns: dict with 'success', 'imported', 'errors' keys """ imported = 0 errors = [] - + try: # Parse CSV - reader = csv.DictReader(TextIOWrapper(file, encoding='utf-8')) - + reader = csv.DictReader(TextIOWrapper(file, encoding="utf-8")) + for row_num, row in enumerate(reader, start=2): # Start at 2 (header is row 1) try: # Parse date - date_str = row.get('Date', '').strip() + date_str = row.get("Date", "").strip() if not date_str: errors.append(f"Row {row_num}: Missing date") continue - + # Parse project - project_name = row.get('Project', '').strip() + project_name = row.get("Project", "").strip() project_id = default_project_id - + if project_name and not project_id: # Find or create project project = self.project_repo.find_one_by(name=project_name) @@ -63,135 +58,107 @@ def import_time_entries_csv( errors.append(f"Row {row_num}: Project '{project_name}' not found") continue project_id = project.id - + if not project_id: errors.append(f"Row {row_num}: No project specified") continue - + # Parse times - start_time_str = row.get('Start Time', '').strip() - end_time_str = row.get('End Time', '').strip() - + start_time_str = row.get("Start Time", "").strip() + end_time_str = row.get("End Time", "").strip() + if not start_time_str or not end_time_str: errors.append(f"Row {row_num}: Missing start or end time") continue - + try: - start_time = datetime.fromisoformat(start_time_str.replace('Z', '+00:00')) - end_time = datetime.fromisoformat(end_time_str.replace('Z', '+00:00')) + start_time = datetime.fromisoformat(start_time_str.replace("Z", "+00:00")) + end_time = datetime.fromisoformat(end_time_str.replace("Z", "+00:00")) except ValueError: errors.append(f"Row {row_num}: Invalid time format") continue - + # Create entry result = self.time_tracking_service.create_manual_entry( user_id=user_id, project_id=project_id, start_time=start_time, end_time=end_time, - notes=row.get('Notes', '').strip() or None, - tags=row.get('Tags', '').strip() or None, - billable=row.get('Billable', 'Yes').strip().lower() == 'yes' + notes=row.get("Notes", "").strip() or None, + tags=row.get("Tags", "").strip() or None, + billable=row.get("Billable", "Yes").strip().lower() == "yes", ) - - if result['success']: + + if result["success"]: imported += 1 else: errors.append(f"Row {row_num}: {result['message']}") - + except Exception as e: errors.append(f"Row {row_num}: {str(e)}") - - return { - 'success': True, - 'imported': imported, - 'errors': errors, - 'total_rows': imported + len(errors) - } - + + return {"success": True, "imported": imported, "errors": errors, "total_rows": imported + len(errors)} + except Exception as e: - return { - 'success': False, - 'imported': imported, - 'errors': [f"Import failed: {str(e)}"], - 'total_rows': 0 - } - - def import_projects_csv( - self, - file, - created_by: int - ) -> Dict[str, Any]: + return {"success": False, "imported": imported, "errors": [f"Import failed: {str(e)}"], "total_rows": 0} + + def import_projects_csv(self, file, created_by: int) -> Dict[str, Any]: """ Import projects from CSV. - + CSV format expected: Name, Client, Description, Billable, Hourly Rate - + Returns: dict with 'success', 'imported', 'errors' keys """ imported = 0 errors = [] - + try: - reader = csv.DictReader(TextIOWrapper(file, encoding='utf-8')) - + reader = csv.DictReader(TextIOWrapper(file, encoding="utf-8")) + for row_num, row in enumerate(reader, start=2): try: - name = row.get('Name', '').strip() + name = row.get("Name", "").strip() if not name: errors.append(f"Row {row_num}: Missing project name") continue - - client_name = row.get('Client', '').strip() + + client_name = row.get("Client", "").strip() if not client_name: errors.append(f"Row {row_num}: Missing client name") continue - + # Find or create client client = self.client_repo.get_by_name(client_name) if not client: # Create client - client_result = self.client_service.create_client( - name=client_name, - created_by=created_by - ) - if not client_result['success']: + client_result = self.client_service.create_client(name=client_name, created_by=created_by) + if not client_result["success"]: errors.append(f"Row {row_num}: Could not create client: {client_result['message']}") continue - client = client_result['client'] - + client = client_result["client"] + # Create project result = self.project_service.create_project( name=name, client_id=client.id, - description=row.get('Description', '').strip() or None, - billable=row.get('Billable', 'Yes').strip().lower() == 'yes', - hourly_rate=Decimal(row.get('Hourly Rate', '0')) if row.get('Hourly Rate') else None, - created_by=created_by + description=row.get("Description", "").strip() or None, + billable=row.get("Billable", "Yes").strip().lower() == "yes", + hourly_rate=Decimal(row.get("Hourly Rate", "0")) if row.get("Hourly Rate") else None, + created_by=created_by, ) - - if result['success']: + + if result["success"]: imported += 1 else: errors.append(f"Row {row_num}: {result['message']}") - + except Exception as e: errors.append(f"Row {row_num}: {str(e)}") - - return { - 'success': True, - 'imported': imported, - 'errors': errors, - 'total_rows': imported + len(errors) - } - - except Exception as e: - return { - 'success': False, - 'imported': imported, - 'errors': [f"Import failed: {str(e)}"], - 'total_rows': 0 - } + return {"success": True, "imported": imported, "errors": errors, "total_rows": imported + len(errors)} + + except Exception as e: + return {"success": False, "imported": imported, "errors": [f"Import failed: {str(e)}"], "total_rows": 0} diff --git a/app/services/integration_service.py b/app/services/integration_service.py index bfd9f043..3844c0b6 100644 --- a/app/services/integration_service.py +++ b/app/services/integration_service.py @@ -17,7 +17,7 @@ class IntegrationService: """ Service for integration management operations. - + Handles: - Creating and managing integrations - OAuth flow management @@ -37,139 +37,101 @@ def register_connector(cls, provider: str, connector_class): def get_connector(cls, integration: Integration) -> Optional[Any]: """ Get connector instance for an integration. - + Args: integration: Integration model instance - + Returns: Connector instance or None """ if integration.provider not in cls._connector_registry: return None - + connector_class = cls._connector_registry[integration.provider] - credentials = IntegrationCredential.query.filter_by( - integration_id=integration.id - ).first() - + credentials = IntegrationCredential.query.filter_by(integration_id=integration.id).first() + return connector_class(integration, credentials) def create_integration( - self, - provider: str, - user_id: int, - name: Optional[str] = None, - config: Optional[Dict] = None + self, provider: str, user_id: int, name: Optional[str] = None, config: Optional[Dict] = None ) -> Dict[str, Any]: """ Create a new integration. - + Args: provider: Provider identifier (e.g., 'jira', 'slack') user_id: User ID who owns the integration name: Optional custom name config: Optional configuration dict - + Returns: Dict with 'success', 'message', and 'integration' """ if provider not in self._connector_registry: - return { - 'success': False, - 'message': f'Provider {provider} is not available.' - } - + return {"success": False, "message": f"Provider {provider} is not available."} + # Check if user already has this integration - existing = Integration.query.filter_by( - provider=provider, - user_id=user_id - ).first() - + existing = Integration.query.filter_by(provider=provider, user_id=user_id).first() + if existing: - return { - 'success': False, - 'message': f'You already have a {provider} integration.' - } - + return {"success": False, "message": f"You already have a {provider} integration."} + connector_class = self._connector_registry[provider] - display_name = connector_class.display_name if hasattr(connector_class, 'display_name') else provider.title() - + display_name = connector_class.display_name if hasattr(connector_class, "display_name") else provider.title() + integration = Integration( name=name or display_name, provider=provider, user_id=user_id, config=config or {}, - is_active=False # Only active when credentials are set up + is_active=False, # Only active when credentials are set up ) - + db.session.add(integration) - if not safe_commit('create_integration', {'provider': provider, 'user_id': user_id}): - return { - 'success': False, - 'message': 'Could not create integration due to a database error.' - } - - emit_event(WebhookEvent.INTEGRATION_CREATED, { - 'integration_id': integration.id, - 'provider': provider, - 'user_id': user_id - }) - - return { - 'success': True, - 'message': 'Integration created successfully.', - 'integration': integration - } + if not safe_commit("create_integration", {"provider": provider, "user_id": user_id}): + return {"success": False, "message": "Could not create integration due to a database error."} + + emit_event( + WebhookEvent.INTEGRATION_CREATED, + {"integration_id": integration.id, "provider": provider, "user_id": user_id}, + ) + + return {"success": True, "message": "Integration created successfully.", "integration": integration} def get_integration(self, integration_id: int, user_id: int) -> Optional[Integration]: """Get integration by ID (with user check).""" - return Integration.query.filter_by( - id=integration_id, - user_id=user_id - ).first() + return Integration.query.filter_by(id=integration_id, user_id=user_id).first() def list_integrations(self, user_id: int) -> List[Integration]: """List all integrations for a user.""" integrations = Integration.query.filter_by(user_id=user_id).order_by(Integration.created_at.desc()).all() - + # Sync is_active status with credentials existence for integration in integrations: - has_credentials = IntegrationCredential.query.filter_by( - integration_id=integration.id - ).first() is not None - + has_credentials = IntegrationCredential.query.filter_by(integration_id=integration.id).first() is not None + # Update is_active if it doesn't match credentials status if integration.is_active != has_credentials: integration.is_active = has_credentials - safe_commit('sync_integration_active_status', {'integration_id': integration.id}) - + safe_commit("sync_integration_active_status", {"integration_id": integration.id}) + return integrations def delete_integration(self, integration_id: int, user_id: int) -> Dict[str, Any]: """Delete an integration.""" integration = self.get_integration(integration_id, user_id) if not integration: - return { - 'success': False, - 'message': 'Integration not found.' - } - + return {"success": False, "message": "Integration not found."} + db.session.delete(integration) - if not safe_commit('delete_integration', {'integration_id': integration_id}): - return { - 'success': False, - 'message': 'Could not delete integration due to a database error.' - } - - emit_event(WebhookEvent.INTEGRATION_DELETED, { - 'integration_id': integration_id, - 'provider': integration.provider - }) - - return { - 'success': True, - 'message': 'Integration deleted successfully.' - } + if not safe_commit("delete_integration", {"integration_id": integration_id}): + return {"success": False, "message": "Could not delete integration due to a database error."} + + emit_event( + WebhookEvent.INTEGRATION_DELETED, {"integration_id": integration_id, "provider": integration.provider} + ) + + return {"success": True, "message": "Integration deleted successfully."} def save_credentials( self, @@ -177,78 +139,57 @@ def save_credentials( access_token: str, refresh_token: Optional[str] = None, expires_at: Optional[datetime] = None, - token_type: str = 'Bearer', + token_type: str = "Bearer", scope: Optional[str] = None, - extra_data: Optional[Dict] = None + extra_data: Optional[Dict] = None, ) -> Dict[str, Any]: """Save OAuth credentials for an integration.""" integration = Integration.query.get(integration_id) if not integration: - return { - 'success': False, - 'message': 'Integration not found.' - } - + return {"success": False, "message": "Integration not found."} + # Get or create credentials - credentials = IntegrationCredential.query.filter_by( - integration_id=integration_id - ).first() - + credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first() + if not credentials: credentials = IntegrationCredential(integration_id=integration_id) db.session.add(credentials) - + credentials.access_token = access_token credentials.refresh_token = refresh_token credentials.expires_at = expires_at credentials.token_type = token_type credentials.scope = scope credentials.extra_data = extra_data or {} - + # Mark integration as active when credentials are saved integration.is_active = True - - if not safe_commit('save_integration_credentials', {'integration_id': integration_id}): - return { - 'success': False, - 'message': 'Could not save credentials due to a database error.' - } - - return { - 'success': True, - 'message': 'Credentials saved successfully.', - 'credentials': credentials - } + + if not safe_commit("save_integration_credentials", {"integration_id": integration_id}): + return {"success": False, "message": "Could not save credentials due to a database error."} + + return {"success": True, "message": "Credentials saved successfully.", "credentials": credentials} def test_connection(self, integration_id: int, user_id: int) -> Dict[str, Any]: """Test connection to integrated service.""" integration = self.get_integration(integration_id, user_id) if not integration: - return { - 'success': False, - 'message': 'Integration not found.' - } - + return {"success": False, "message": "Integration not found."} + connector = self.get_connector(integration) if not connector: - return { - 'success': False, - 'message': f'Connector for {integration.provider} is not available.' - } - + return {"success": False, "message": f"Connector for {integration.provider} is not available."} + try: result = connector.test_connection() - + # Log event - self._log_event(integration_id, 'test_connection', result.get('success', False), result.get('message')) - + self._log_event(integration_id, "test_connection", result.get("success", False), result.get("message")) + return result except Exception as e: logger.error(f"Error testing connection for integration {integration_id}: {e}") - return { - 'success': False, - 'message': f'Error testing connection: {str(e)}' - } + return {"success": False, "message": f"Error testing connection: {str(e)}"} def _log_event( self, @@ -256,29 +197,30 @@ def _log_event( event_type: str, status: bool, message: Optional[str] = None, - metadata: Optional[Dict] = None + metadata: Optional[Dict] = None, ): """Log an integration event.""" event = IntegrationEvent( integration_id=integration_id, event_type=event_type, - status='success' if status else 'error', + status="success" if status else "error", message=message, - event_metadata=metadata or {} + event_metadata=metadata or {}, ) db.session.add(event) - safe_commit('log_integration_event', {'integration_id': integration_id}) + safe_commit("log_integration_event", {"integration_id": integration_id}) @classmethod def get_available_providers(cls) -> List[Dict[str, Any]]: """Get list of available integration providers.""" providers = [] for provider, connector_class in cls._connector_registry.items(): - providers.append({ - 'provider': provider, - 'display_name': getattr(connector_class, 'display_name', provider.title()), - 'description': getattr(connector_class, 'description', ''), - 'icon': getattr(connector_class, 'icon', 'plug') - }) + providers.append( + { + "provider": provider, + "display_name": getattr(connector_class, "display_name", provider.title()), + "description": getattr(connector_class, "description", ""), + "icon": getattr(connector_class, "icon", "plug"), + } + ) return providers - diff --git a/app/services/inventory_report_service.py b/app/services/inventory_report_service.py index dc4572ee..87639ed2 100644 --- a/app/services/inventory_report_service.py +++ b/app/services/inventory_report_service.py @@ -13,7 +13,7 @@ class InventoryReportService: """ Service for inventory reporting and analytics. - + Provides methods for: - Stock valuation calculations - Inventory turnover analysis @@ -22,19 +22,16 @@ class InventoryReportService: """ def get_stock_valuation( - self, - warehouse_id: Optional[int] = None, - category: Optional[str] = None, - currency_code: Optional[str] = None + self, warehouse_id: Optional[int] = None, category: Optional[str] = None, currency_code: Optional[str] = None ) -> Dict[str, Any]: """ Calculate total stock valuation. - + Args: warehouse_id: Filter by specific warehouse (None for all) category: Filter by stock item category (None for all) currency_code: Filter by currency (None for all) - + Returns: dict with valuation data including: - total_value: Total inventory value @@ -43,20 +40,13 @@ def get_stock_valuation( - item_details: Detailed item-level valuation """ # Base query: join WarehouseStock with StockItem - query = db.session.query( - WarehouseStock, - StockItem, - Warehouse - ).join( - StockItem, WarehouseStock.stock_item_id == StockItem.id - ).join( - Warehouse, WarehouseStock.warehouse_id == Warehouse.id - ).filter( - StockItem.is_active == True, - StockItem.is_trackable == True, - WarehouseStock.quantity_on_hand > 0 + query = ( + db.session.query(WarehouseStock, StockItem, Warehouse) + .join(StockItem, WarehouseStock.stock_item_id == StockItem.id) + .join(Warehouse, WarehouseStock.warehouse_id == Warehouse.id) + .filter(StockItem.is_active == True, StockItem.is_trackable == True, WarehouseStock.quantity_on_hand > 0) ) - + # Apply filters if warehouse_id: query = query.filter(WarehouseStock.warehouse_id == warehouse_id) @@ -64,83 +54,78 @@ def get_stock_valuation( query = query.filter(StockItem.category == category) if currency_code: query = query.filter(StockItem.currency_code == currency_code) - + results = query.all() - + # Calculate totals - total_value = Decimal('0') + total_value = Decimal("0") by_warehouse = {} by_category = {} item_details = [] - + for stock, item, warehouse in results: # Use default_cost or 0 if not set - cost = item.default_cost or Decimal('0') + cost = item.default_cost or Decimal("0") quantity = stock.quantity_on_hand value = cost * quantity - + total_value += value - + # By warehouse warehouse_key = f"{warehouse.name} ({warehouse.code})" if warehouse_key not in by_warehouse: by_warehouse[warehouse_key] = { - 'warehouse_id': warehouse.id, - 'warehouse_name': warehouse.name, - 'warehouse_code': warehouse.code, - 'value': Decimal('0'), - 'currency': item.currency_code + "warehouse_id": warehouse.id, + "warehouse_name": warehouse.name, + "warehouse_code": warehouse.code, + "value": Decimal("0"), + "currency": item.currency_code, } - by_warehouse[warehouse_key]['value'] += value - + by_warehouse[warehouse_key]["value"] += value + # By category - cat = item.category or 'Uncategorized' + cat = item.category or "Uncategorized" if cat not in by_category: - by_category[cat] = { - 'category': cat, - 'value': Decimal('0'), - 'currency': item.currency_code - } - by_category[cat]['value'] += value - + by_category[cat] = {"category": cat, "value": Decimal("0"), "currency": item.currency_code} + by_category[cat]["value"] += value + # Item details - item_details.append({ - 'item_id': item.id, - 'sku': item.sku, - 'name': item.name, - 'category': item.category, - 'warehouse_id': warehouse.id, - 'warehouse_name': warehouse.name, - 'quantity': float(quantity), - 'cost': float(cost), - 'value': float(value), - 'currency': item.currency_code - }) - + item_details.append( + { + "item_id": item.id, + "sku": item.sku, + "name": item.name, + "category": item.category, + "warehouse_id": warehouse.id, + "warehouse_name": warehouse.name, + "quantity": float(quantity), + "cost": float(cost), + "value": float(value), + "currency": item.currency_code, + } + ) + return { - 'total_value': float(total_value), - 'by_warehouse': {k: {**v, 'value': float(v['value'])} for k, v in by_warehouse.items()}, - 'by_category': {k: {**v, 'value': float(v['value'])} for k, v in by_category.items()}, - 'item_details': item_details, - 'currency': currency_code or 'EUR', - 'warehouse_id': warehouse_id, - 'category': category + "total_value": float(total_value), + "by_warehouse": {k: {**v, "value": float(v["value"])} for k, v in by_warehouse.items()}, + "by_category": {k: {**v, "value": float(v["value"])} for k, v in by_category.items()}, + "item_details": item_details, + "currency": currency_code or "EUR", + "warehouse_id": warehouse_id, + "category": category, } def get_inventory_turnover( - self, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None, - item_id: Optional[int] = None + self, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, item_id: Optional[int] = None ) -> Dict[str, Any]: """ Calculate inventory turnover analysis. - + Args: start_date: Start of analysis period end_date: End of analysis period item_id: Specific item to analyze (None for all) - + Returns: dict with turnover data """ @@ -148,19 +133,19 @@ def get_inventory_turnover( start_date = datetime.utcnow() - timedelta(days=365) if not end_date: end_date = datetime.utcnow() - + # Get movements in the period query = StockMovement.query.filter( StockMovement.movement_date >= start_date, StockMovement.movement_date <= end_date, - StockMovement.movement_type.in_(['sale', 'usage', 'consumption']) + StockMovement.movement_type.in_(["sale", "usage", "consumption"]), ) - + if item_id: query = query.filter(StockMovement.stock_item_id == item_id) - + movements = query.all() - + # Aggregate by item item_turnover = {} for movement in movements: @@ -169,75 +154,71 @@ def get_inventory_turnover( item = StockItem.query.get(item_id) if not item: continue - + # Get average stock level during period avg_stock = self._calculate_average_stock(item_id, start_date, end_date) - + item_turnover[item_id] = { - 'item_id': item_id, - 'sku': item.sku, - 'name': item.name, - 'quantity_sold': Decimal('0'), - 'avg_stock': avg_stock, - 'turnover_rate': Decimal('0'), - 'days_on_hand': Decimal('0') + "item_id": item_id, + "sku": item.sku, + "name": item.name, + "quantity_sold": Decimal("0"), + "avg_stock": avg_stock, + "turnover_rate": Decimal("0"), + "days_on_hand": Decimal("0"), } - - item_turnover[item_id]['quantity_sold'] += abs(movement.quantity) - + + item_turnover[item_id]["quantity_sold"] += abs(movement.quantity) + # Calculate turnover rates for item_id, data in item_turnover.items(): - if data['avg_stock'] > 0: + if data["avg_stock"] > 0: days = (end_date - start_date).days - data['turnover_rate'] = data['quantity_sold'] / data['avg_stock'] if days > 0 else Decimal('0') - data['days_on_hand'] = days / data['turnover_rate'] if data['turnover_rate'] > 0 else Decimal('0') + data["turnover_rate"] = data["quantity_sold"] / data["avg_stock"] if days > 0 else Decimal("0") + data["days_on_hand"] = days / data["turnover_rate"] if data["turnover_rate"] > 0 else Decimal("0") else: - data['turnover_rate'] = Decimal('0') - data['days_on_hand'] = Decimal('0') - + data["turnover_rate"] = Decimal("0") + data["days_on_hand"] = Decimal("0") + # Convert to float for JSON serialization - data['quantity_sold'] = float(data['quantity_sold']) - data['avg_stock'] = float(data['avg_stock']) - data['turnover_rate'] = float(data['turnover_rate']) - data['days_on_hand'] = float(data['days_on_hand']) - + data["quantity_sold"] = float(data["quantity_sold"]) + data["avg_stock"] = float(data["avg_stock"]) + data["turnover_rate"] = float(data["turnover_rate"]) + data["days_on_hand"] = float(data["days_on_hand"]) + return { - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat(), - 'items': list(item_turnover.values()) + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "items": list(item_turnover.values()), } - def _calculate_average_stock( - self, - item_id: int, - start_date: datetime, - end_date: datetime - ) -> Decimal: + def _calculate_average_stock(self, item_id: int, start_date: datetime, end_date: datetime) -> Decimal: """Calculate average stock level for an item during a period.""" # Get stock levels at start and end start_stock = self._get_stock_at_date(item_id, start_date) end_stock = self._get_stock_at_date(item_id, end_date) - + # Simple average (can be enhanced with more data points) return (start_stock + end_stock) / 2 def _get_stock_at_date(self, item_id: int, date: datetime) -> Decimal: """Get stock level for an item at a specific date.""" # Get the most recent movement before or at the date - movement = StockMovement.query.filter( - StockMovement.stock_item_id == item_id, - StockMovement.movement_date <= date - ).order_by(StockMovement.movement_date.desc()).first() - + movement = ( + StockMovement.query.filter(StockMovement.stock_item_id == item_id, StockMovement.movement_date <= date) + .order_by(StockMovement.movement_date.desc()) + .first() + ) + if movement: # Get stock after this movement # This is simplified - in reality, we'd need to track historical stock levels stock = WarehouseStock.query.filter_by(stock_item_id=item_id).first() - return stock.quantity_on_hand if stock else Decimal('0') - + return stock.quantity_on_hand if stock else Decimal("0") + # No movements, get current stock stock = WarehouseStock.query.filter_by(stock_item_id=item_id).first() - return stock.quantity_on_hand if stock else Decimal('0') + return stock.quantity_on_hand if stock else Decimal("0") def get_movement_history( self, @@ -245,16 +226,16 @@ def get_movement_history( end_date: Optional[datetime] = None, item_id: Optional[int] = None, warehouse_id: Optional[int] = None, - movement_type: Optional[str] = None + movement_type: Optional[str] = None, ) -> Dict[str, Any]: """ Get detailed movement history. - + Returns: dict with movement history data """ query = StockMovement.query - + if start_date: query = query.filter(StockMovement.movement_date >= start_date) if end_date: @@ -265,23 +246,25 @@ def get_movement_history( query = query.filter(StockMovement.warehouse_id == warehouse_id) if movement_type: query = query.filter(StockMovement.movement_type == movement_type) - + movements = query.order_by(StockMovement.movement_date.desc()).all() - + return { - 'movements': [{ - 'id': m.id, - 'date': m.movement_date.isoformat() if m.movement_date else None, - 'item_id': m.stock_item_id, - 'item_sku': m.stock_item.sku if m.stock_item else None, - 'item_name': m.stock_item.name if m.stock_item else None, - 'warehouse_id': m.warehouse_id, - 'warehouse_name': m.warehouse.name if m.warehouse else None, - 'quantity': float(m.quantity), - 'type': m.movement_type, - 'reference': m.reference, - 'notes': m.notes - } for m in movements], - 'total_movements': len(movements) + "movements": [ + { + "id": m.id, + "date": m.movement_date.isoformat() if m.movement_date else None, + "item_id": m.stock_item_id, + "item_sku": m.stock_item.sku if m.stock_item else None, + "item_name": m.stock_item.name if m.stock_item else None, + "warehouse_id": m.warehouse_id, + "warehouse_name": m.warehouse.name if m.warehouse else None, + "quantity": float(m.quantity), + "type": m.movement_type, + "reference": m.reference, + "notes": m.notes, + } + for m in movements + ], + "total_movements": len(movements), } - diff --git a/app/services/invoice_approval_service.py b/app/services/invoice_approval_service.py index daed40e5..97c45638 100644 --- a/app/services/invoice_approval_service.py +++ b/app/services/invoice_approval_service.py @@ -15,270 +15,203 @@ class InvoiceApprovalService: """ Service for invoice approval workflow operations. """ - + def request_approval( - self, - invoice_id: int, - requested_by: int, - approvers: List[int], - stages: Optional[List[Dict[str, Any]]] = None + self, invoice_id: int, requested_by: int, approvers: List[int], stages: Optional[List[Dict[str, Any]]] = None ) -> Dict[str, Any]: """ Request approval for an invoice. - + Args: invoice_id: Invoice ID requested_by: User ID requesting approval approvers: List of user IDs who need to approve stages: Optional list of approval stages with custom configuration - + Returns: dict with 'success', 'message', and 'approval' keys """ try: invoice = Invoice.query.get(invoice_id) if not invoice: - return { - 'success': False, - 'message': 'Invoice not found.' - } - + return {"success": False, "message": "Invoice not found."} + # Check if approval already exists - existing = InvoiceApproval.query.filter_by( - invoice_id=invoice_id, - status='pending' - ).first() - + existing = InvoiceApproval.query.filter_by(invoice_id=invoice_id, status="pending").first() + if existing: - return { - 'success': False, - 'message': 'An approval request is already pending for this invoice.' - } - + return {"success": False, "message": "An approval request is already pending for this invoice."} + # Create stages if not provided if not stages: stages = [] for i, approver_id in enumerate(approvers): - stages.append({ - 'stage_number': i + 1, - 'approver_id': approver_id, - 'status': 'pending', - 'comments': None, - 'approved_at': None, - 'rejected_at': None - }) - + stages.append( + { + "stage_number": i + 1, + "approver_id": approver_id, + "status": "pending", + "comments": None, + "approved_at": None, + "rejected_at": None, + } + ) + approval = InvoiceApproval( invoice_id=invoice_id, - status='pending', + status="pending", stages=stages, current_stage=0, total_stages=len(stages), requested_by=requested_by, - requested_at=now_in_app_timezone() + requested_at=now_in_app_timezone(), ) - + db.session.add(approval) - if not safe_commit('request_approval', {'invoice_id': invoice_id}): - return { - 'success': False, - 'message': 'Could not create approval request due to a database error.' - } - - emit_event(WebhookEvent.INVOICE_APPROVAL_REQUESTED, { - 'invoice_id': invoice_id, - 'approval_id': approval.id, - 'requested_by': requested_by, - 'total_stages': len(stages) - }) - - return { - 'success': True, - 'message': 'Approval request created successfully.', - 'approval': approval - } + if not safe_commit("request_approval", {"invoice_id": invoice_id}): + return {"success": False, "message": "Could not create approval request due to a database error."} + + emit_event( + WebhookEvent.INVOICE_APPROVAL_REQUESTED, + { + "invoice_id": invoice_id, + "approval_id": approval.id, + "requested_by": requested_by, + "total_stages": len(stages), + }, + ) + + return {"success": True, "message": "Approval request created successfully.", "approval": approval} except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error creating approval request: {str(e)}' - } - - def approve( - self, - approval_id: int, - approver_id: int, - comments: Optional[str] = None - ) -> Dict[str, Any]: + return {"success": False, "message": f"Error creating approval request: {str(e)}"} + + def approve(self, approval_id: int, approver_id: int, comments: Optional[str] = None) -> Dict[str, Any]: """ Approve an invoice at the current stage. - + Returns: dict with 'success', 'message', and 'approval' keys """ try: approval = InvoiceApproval.query.get(approval_id) if not approval: - return { - 'success': False, - 'message': 'Approval not found.' - } - - if approval.status != 'pending': - return { - 'success': False, - 'message': f'Approval is not pending (current status: {approval.status}).' - } - + return {"success": False, "message": "Approval not found."} + + if approval.status != "pending": + return {"success": False, "message": f"Approval is not pending (current status: {approval.status})."} + # Get current stage current_stage_data = approval.stages[approval.current_stage] if approval.stages else None if not current_stage_data: - return { - 'success': False, - 'message': 'Invalid approval stage.' - } - + return {"success": False, "message": "Invalid approval stage."} + # Check if user is the approver for this stage - if current_stage_data.get('approver_id') != approver_id: - return { - 'success': False, - 'message': 'You are not authorized to approve at this stage.' - } - + if current_stage_data.get("approver_id") != approver_id: + return {"success": False, "message": "You are not authorized to approve at this stage."} + # Update current stage - current_stage_data['status'] = 'approved' - current_stage_data['comments'] = comments - current_stage_data['approved_at'] = now_in_app_timezone().isoformat() - + current_stage_data["status"] = "approved" + current_stage_data["comments"] = comments + current_stage_data["approved_at"] = now_in_app_timezone().isoformat() + approval.stages[approval.current_stage] = current_stage_data - + # Move to next stage or complete if approval.current_stage < approval.total_stages - 1: approval.current_stage += 1 else: # All stages approved - approval.status = 'approved' + approval.status = "approved" approval.approved_by = approver_id approval.approved_at = now_in_app_timezone() - + # Update invoice status invoice = Invoice.query.get(approval.invoice_id) - if invoice and invoice.status == 'draft': - invoice.status = 'sent' - - if not safe_commit('approve_invoice', {'approval_id': approval_id}): - return { - 'success': False, - 'message': 'Could not update approval due to a database error.' - } - + if invoice and invoice.status == "draft": + invoice.status = "sent" + + if not safe_commit("approve_invoice", {"approval_id": approval_id}): + return {"success": False, "message": "Could not update approval due to a database error."} + # Emit event if fully approved - if approval.status == 'approved': - emit_event(WebhookEvent.INVOICE_APPROVED, { - 'invoice_id': approval.invoice_id, - 'approval_id': approval.id, - 'approved_by': approver_id - }) - - return { - 'success': True, - 'message': 'Invoice approved successfully.', - 'approval': approval - } + if approval.status == "approved": + emit_event( + WebhookEvent.INVOICE_APPROVED, + {"invoice_id": approval.invoice_id, "approval_id": approval.id, "approved_by": approver_id}, + ) + + return {"success": True, "message": "Invoice approved successfully.", "approval": approval} except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error approving invoice: {str(e)}' - } - - def reject( - self, - approval_id: int, - rejector_id: int, - reason: str - ) -> Dict[str, Any]: + return {"success": False, "message": f"Error approving invoice: {str(e)}"} + + def reject(self, approval_id: int, rejector_id: int, reason: str) -> Dict[str, Any]: """ Reject an invoice approval. - + Returns: dict with 'success', 'message', and 'approval' keys """ try: approval = InvoiceApproval.query.get(approval_id) if not approval: - return { - 'success': False, - 'message': 'Approval not found.' - } - - if approval.status != 'pending': - return { - 'success': False, - 'message': f'Approval is not pending (current status: {approval.status}).' - } - + return {"success": False, "message": "Approval not found."} + + if approval.status != "pending": + return {"success": False, "message": f"Approval is not pending (current status: {approval.status})."} + # Update approval - approval.status = 'rejected' + approval.status = "rejected" approval.rejected_by = rejector_id approval.rejected_at = now_in_app_timezone() approval.rejection_reason = reason - + # Update current stage if approval.stages and approval.current_stage < len(approval.stages): current_stage_data = approval.stages[approval.current_stage] - current_stage_data['status'] = 'rejected' - current_stage_data['comments'] = reason - current_stage_data['rejected_at'] = now_in_app_timezone().isoformat() + current_stage_data["status"] = "rejected" + current_stage_data["comments"] = reason + current_stage_data["rejected_at"] = now_in_app_timezone().isoformat() approval.stages[approval.current_stage] = current_stage_data - - if not safe_commit('reject_invoice', {'approval_id': approval_id}): - return { - 'success': False, - 'message': 'Could not update approval due to a database error.' - } - - emit_event(WebhookEvent.INVOICE_REJECTED, { - 'invoice_id': approval.invoice_id, - 'approval_id': approval.id, - 'rejected_by': rejector_id, - 'reason': reason - }) - - return { - 'success': True, - 'message': 'Invoice approval rejected.', - 'approval': approval - } + + if not safe_commit("reject_invoice", {"approval_id": approval_id}): + return {"success": False, "message": "Could not update approval due to a database error."} + + emit_event( + WebhookEvent.INVOICE_REJECTED, + { + "invoice_id": approval.invoice_id, + "approval_id": approval.id, + "rejected_by": rejector_id, + "reason": reason, + }, + ) + + return {"success": True, "message": "Invoice approval rejected.", "approval": approval} except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error rejecting invoice: {str(e)}' - } - + return {"success": False, "message": f"Error rejecting invoice: {str(e)}"} + def get_approval(self, approval_id: int) -> Optional[InvoiceApproval]: """Get an approval by ID""" return InvoiceApproval.query.get(approval_id) - + def get_invoice_approval(self, invoice_id: int) -> Optional[InvoiceApproval]: """Get the current approval for an invoice""" - return InvoiceApproval.query.filter_by( - invoice_id=invoice_id - ).order_by(InvoiceApproval.created_at.desc()).first() - - def list_pending_approvals( - self, - user_id: Optional[int] = None - ) -> List[InvoiceApproval]: + return ( + InvoiceApproval.query.filter_by(invoice_id=invoice_id).order_by(InvoiceApproval.created_at.desc()).first() + ) + + def list_pending_approvals(self, user_id: Optional[int] = None) -> List[InvoiceApproval]: """ List pending approvals. - + If user_id is provided, returns approvals where user is the current approver. Otherwise, returns all pending approvals. """ - query = InvoiceApproval.query.filter_by(status='pending') - + query = InvoiceApproval.query.filter_by(status="pending") + if user_id: # Filter to approvals where user is current approver # This requires checking the current stage's approver_id @@ -288,9 +221,8 @@ def list_pending_approvals( for approval in all_pending: if approval.stages and approval.current_stage < len(approval.stages): current_stage = approval.stages[approval.current_stage] - if current_stage.get('approver_id') == user_id: + if current_stage.get("approver_id") == user_id: result.append(approval) return result - - return query.all() + return query.all() diff --git a/app/services/invoice_service.py b/app/services/invoice_service.py index 5ef60843..20d73e7a 100644 --- a/app/services/invoice_service.py +++ b/app/services/invoice_service.py @@ -16,11 +16,11 @@ class InvoiceService: """Service for invoice operations""" - + def __init__(self): self.invoice_repo = InvoiceRepository() self.project_repo = ProjectRepository() - + def create_invoice_from_time_entries( self, project_id: int, @@ -28,59 +28,49 @@ def create_invoice_from_time_entries( created_by: int, issue_date: Optional[date] = None, due_date: Optional[date] = None, - include_expenses: bool = False + include_expenses: bool = False, ) -> Dict[str, Any]: """ Create an invoice from time entries. - + Returns: dict with 'success', 'message', and 'invoice' keys """ # Validate project project = self.project_repo.get_by_id(project_id) if not project: - return { - 'success': False, - 'message': 'Invalid project', - 'error': 'invalid_project' - } - + return {"success": False, "message": "Invalid project", "error": "invalid_project"} + # Get time entries entries = TimeEntry.query.filter( - TimeEntry.id.in_(time_entry_ids), - TimeEntry.project_id == project_id, - TimeEntry.billable == True + TimeEntry.id.in_(time_entry_ids), TimeEntry.project_id == project_id, TimeEntry.billable == True ).all() - + if not entries: - return { - 'success': False, - 'message': 'No billable time entries found', - 'error': 'no_entries' - } - + return {"success": False, "message": "No billable time entries found", "error": "no_entries"} + # Generate invoice number invoice_number = self.invoice_repo.generate_invoice_number() - + # Calculate totals - subtotal = Decimal('0.00') + subtotal = Decimal("0.00") for entry in entries: if entry.duration_seconds: hours = Decimal(str(entry.duration_seconds / 3600)) - rate = project.hourly_rate or Decimal('0.00') + rate = project.hourly_rate or Decimal("0.00") subtotal += hours * rate - + # Get tax rate (from project or default) - tax_rate = Decimal('0.00') # Should come from project/client settings + tax_rate = Decimal("0.00") # Should come from project/client settings tax_amount = subtotal * (tax_rate / 100) total_amount = subtotal + tax_amount - + # Create invoice invoice = self.invoice_repo.create( invoice_number=invoice_number, project_id=project_id, client_id=project.client_id, - client_name=project.client.name if project.client else '', + client_name=project.client.name if project.client else "", issue_date=issue_date or date.today(), due_date=due_date or date.today(), status=InvoiceStatus.DRAFT.value, @@ -88,206 +78,182 @@ def create_invoice_from_time_entries( tax_rate=tax_rate, tax_amount=tax_amount, total_amount=total_amount, - currency_code='EUR', # Should come from project/client - created_by=created_by + currency_code="EUR", # Should come from project/client + created_by=created_by, ) - + # Create invoice items from time entries for entry in entries: if entry.duration_seconds: hours = Decimal(str(entry.duration_seconds / 3600)) - rate = project.hourly_rate or Decimal('0.00') + rate = project.hourly_rate or Decimal("0.00") amount = hours * rate - + item = InvoiceItem( invoice_id=invoice.id, description=f"Time entry: {entry.notes or 'No description'}", quantity=hours, unit_price=rate, - amount=amount + amount=amount, ) db.session.add(item) - - if not safe_commit('create_invoice', {'project_id': project_id, 'created_by': created_by}): + + if not safe_commit("create_invoice", {"project_id": project_id, "created_by": created_by}): return { - 'success': False, - 'message': 'Could not create invoice due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create invoice due to a database error", + "error": "database_error", } - + # Emit domain event - emit_event(WebhookEvent.INVOICE_CREATED.value, { - 'invoice_id': invoice.id, - 'project_id': project_id, - 'client_id': project.client_id - }) - - return { - 'success': True, - 'message': 'Invoice created successfully', - 'invoice': invoice - } - + emit_event( + WebhookEvent.INVOICE_CREATED.value, + {"invoice_id": invoice.id, "project_id": project_id, "client_id": project.client_id}, + ) + + return {"success": True, "message": "Invoice created successfully", "invoice": invoice} + def mark_as_sent(self, invoice_id: int) -> Dict[str, Any]: """Mark an invoice as sent""" invoice = self.invoice_repo.mark_as_sent(invoice_id) - + if not invoice: + return {"success": False, "message": "Invoice not found", "error": "not_found"} + + if not safe_commit("mark_invoice_sent", {"invoice_id": invoice_id}): return { - 'success': False, - 'message': 'Invoice not found', - 'error': 'not_found' - } - - if not safe_commit('mark_invoice_sent', {'invoice_id': invoice_id}): - return { - 'success': False, - 'message': 'Could not update invoice due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not update invoice due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Invoice marked as sent', - 'invoice': invoice - } - + + return {"success": True, "message": "Invoice marked as sent", "invoice": invoice} + def mark_as_paid( self, invoice_id: int, payment_date: Optional[date] = None, payment_method: Optional[str] = None, - payment_reference: Optional[str] = None + payment_reference: Optional[str] = None, ) -> Dict[str, Any]: """Mark an invoice as paid""" invoice = self.invoice_repo.mark_as_paid( invoice_id=invoice_id, payment_date=payment_date, payment_method=payment_method, - payment_reference=payment_reference + payment_reference=payment_reference, ) - + if not invoice: + return {"success": False, "message": "Invoice not found", "error": "not_found"} + + if not safe_commit("mark_invoice_paid", {"invoice_id": invoice_id}): return { - 'success': False, - 'message': 'Invoice not found', - 'error': 'not_found' - } - - if not safe_commit('mark_invoice_paid', {'invoice_id': invoice_id}): - return { - 'success': False, - 'message': 'Could not update invoice due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not update invoice due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Invoice marked as paid', - 'invoice': invoice - } - + + return {"success": True, "message": "Invoice marked as paid", "invoice": invoice} + def list_invoices( self, status: Optional[str] = None, payment_status: Optional[str] = None, search: Optional[str] = None, user_id: Optional[int] = None, - is_admin: bool = False + is_admin: bool = False, ) -> Dict[str, Any]: """ List invoices with filtering. Uses eager loading to prevent N+1 queries. - + Args: status: Filter by invoice status payment_status: Filter by payment status search: Search in invoice number or client name user_id: User ID for filtering (non-admin users) is_admin: Whether user is admin - + Returns: dict with 'invoices', 'summary' keys """ from sqlalchemy.orm import joinedload from datetime import date - + query = self.invoice_repo.query() - + # Eagerly load relations to prevent N+1 - query = query.options( - joinedload(Invoice.project), - joinedload(Invoice.client) - ) - + query = query.options(joinedload(Invoice.project), joinedload(Invoice.client)) + # Permission filter - non-admins only see their invoices if not is_admin and user_id: query = query.filter(Invoice.created_by == user_id) - + # Apply filters if status: query = query.filter(Invoice.status == status) - + if payment_status: query = query.filter(Invoice.payment_status == payment_status) - + if search: like = f"%{search}%" - query = query.filter( - db.or_( - Invoice.invoice_number.ilike(like), - Invoice.client_name.ilike(like) - ) - ) - + query = query.filter(db.or_(Invoice.invoice_number.ilike(like), Invoice.client_name.ilike(like))) + # Order by creation date invoices = query.order_by(Invoice.created_at.desc()).all() - + # Calculate overdue status today = date.today() for invoice in invoices: - if invoice.due_date and invoice.due_date < today and invoice.payment_status != 'fully_paid' and invoice.status != 'paid': + if ( + invoice.due_date + and invoice.due_date < today + and invoice.payment_status != "fully_paid" + and invoice.status != "paid" + ): invoice._is_overdue = True else: invoice._is_overdue = False - + # Calculate summary statistics if is_admin: all_invoices = Invoice.query.all() else: all_invoices = Invoice.query.filter_by(created_by=user_id).all() if user_id else [] - + total_invoices = len(all_invoices) total_amount = sum(invoice.total_amount for invoice in all_invoices) actual_paid_amount = sum(invoice.amount_paid or 0 for invoice in all_invoices) - fully_paid_amount = sum(invoice.total_amount for invoice in all_invoices if invoice.payment_status == 'fully_paid') - partially_paid_amount = sum(invoice.amount_paid or 0 for invoice in all_invoices if invoice.payment_status == 'partially_paid') - overdue_amount = sum(invoice.outstanding_amount for invoice in all_invoices if invoice.status == 'overdue') - + fully_paid_amount = sum( + invoice.total_amount for invoice in all_invoices if invoice.payment_status == "fully_paid" + ) + partially_paid_amount = sum( + invoice.amount_paid or 0 for invoice in all_invoices if invoice.payment_status == "partially_paid" + ) + overdue_amount = sum(invoice.outstanding_amount for invoice in all_invoices if invoice.status == "overdue") + summary = { - 'total_invoices': total_invoices, - 'total_amount': float(total_amount), - 'paid_amount': float(actual_paid_amount), - 'fully_paid_amount': float(fully_paid_amount), - 'partially_paid_amount': float(partially_paid_amount), - 'overdue_amount': float(overdue_amount), - 'outstanding_amount': float(total_amount - actual_paid_amount) - } - - return { - 'invoices': invoices, - 'summary': summary + "total_invoices": total_invoices, + "total_amount": float(total_amount), + "paid_amount": float(actual_paid_amount), + "fully_paid_amount": float(fully_paid_amount), + "partially_paid_amount": float(partially_paid_amount), + "overdue_amount": float(overdue_amount), + "outstanding_amount": float(total_amount - actual_paid_amount), } - + + return {"invoices": invoices, "summary": summary} + def get_invoice_with_details(self, invoice_id: int) -> Optional[Invoice]: """ Get invoice with all related data using eager loading. - + Args: invoice_id: The invoice ID - + Returns: Invoice with eagerly loaded relations, or None if not found """ return self.invoice_repo.get_with_relations(invoice_id) - diff --git a/app/services/notification_service.py b/app/services/notification_service.py index d012d29a..f1770742 100644 --- a/app/services/notification_service.py +++ b/app/services/notification_service.py @@ -10,59 +10,42 @@ class NotificationService: """Service for notifications and events""" - + def notify_time_entry_created(self, entry_id: int, user_id: int, project_id: int) -> None: """Notify that a time entry was created""" try: dispatch_webhook( event=WebhookEvent.TIME_ENTRY_CREATED.value, - data={ - 'entry_id': entry_id, - 'user_id': user_id, - 'project_id': project_id - } + data={"entry_id": entry_id, "user_id": user_id, "project_id": project_id}, ) except Exception as e: current_app.logger.error(f"Failed to dispatch time entry created webhook: {e}") - + def notify_time_entry_updated(self, entry_id: int, user_id: int, project_id: int) -> None: """Notify that a time entry was updated""" try: dispatch_webhook( event=WebhookEvent.TIME_ENTRY_UPDATED.value, - data={ - 'entry_id': entry_id, - 'user_id': user_id, - 'project_id': project_id - } + data={"entry_id": entry_id, "user_id": user_id, "project_id": project_id}, ) except Exception as e: current_app.logger.error(f"Failed to dispatch time entry updated webhook: {e}") - + def notify_project_created(self, project_id: int, client_id: int) -> None: """Notify that a project was created""" try: dispatch_webhook( - event=WebhookEvent.PROJECT_CREATED.value, - data={ - 'project_id': project_id, - 'client_id': client_id - } + event=WebhookEvent.PROJECT_CREATED.value, data={"project_id": project_id, "client_id": client_id} ) except Exception as e: current_app.logger.error(f"Failed to dispatch project created webhook: {e}") - + def notify_invoice_created(self, invoice_id: int, project_id: int, client_id: int) -> None: """Notify that an invoice was created""" try: dispatch_webhook( event=WebhookEvent.INVOICE_CREATED.value, - data={ - 'invoice_id': invoice_id, - 'project_id': project_id, - 'client_id': client_id - } + data={"invoice_id": invoice_id, "project_id": project_id, "client_id": client_id}, ) except Exception as e: current_app.logger.error(f"Failed to dispatch invoice created webhook: {e}") - diff --git a/app/services/payment_gateway_service.py b/app/services/payment_gateway_service.py index 2a717c75..5042fab0 100644 --- a/app/services/payment_gateway_service.py +++ b/app/services/payment_gateway_service.py @@ -19,23 +19,19 @@ class PaymentGatewayService: """ Service for payment gateway operations. """ - + def create_gateway( - self, - name: str, - provider: str, - config: Dict[str, Any], - is_test_mode: bool = False + self, name: str, provider: str, config: Dict[str, Any], is_test_mode: bool = False ) -> Dict[str, Any]: """ Create a payment gateway configuration. - + Args: name: Gateway name (e.g., 'stripe_production') provider: Provider type ('stripe', 'paypal', 'square') config: Configuration dict (will be encrypted) is_test_mode: Whether in test mode - + Returns: dict with 'success', 'message', and 'gateway' keys """ @@ -43,87 +39,65 @@ def create_gateway( # Check if name already exists existing = PaymentGateway.query.filter_by(name=name).first() if existing: - return { - 'success': False, - 'message': 'A gateway with this name already exists.' - } - + return {"success": False, "message": "A gateway with this name already exists."} + # Encrypt config (in production, use proper encryption) # For now, we'll store as JSON string import json + config_json = json.dumps(config) - + gateway = PaymentGateway( - name=name, - provider=provider, - config=config_json, - is_active=True, - is_test_mode=is_test_mode + name=name, provider=provider, config=config_json, is_active=True, is_test_mode=is_test_mode ) - + db.session.add(gateway) - if not safe_commit('create_gateway', {'name': name}): - return { - 'success': False, - 'message': 'Could not create gateway due to a database error.' - } - - return { - 'success': True, - 'message': 'Payment gateway created successfully.', - 'gateway': gateway - } + if not safe_commit("create_gateway", {"name": name}): + return {"success": False, "message": "Could not create gateway due to a database error."} + + return {"success": True, "message": "Payment gateway created successfully.", "gateway": gateway} except Exception as e: db.session.rollback() logger.error(f"Error creating payment gateway: {e}") - return { - 'success': False, - 'message': f'Error creating gateway: {str(e)}' - } - + return {"success": False, "message": f"Error creating gateway: {str(e)}"} + def get_gateway(self, gateway_id: int) -> Optional[PaymentGateway]: """Get a gateway by ID""" return PaymentGateway.query.get(gateway_id) - + def get_active_gateway(self, provider: Optional[str] = None) -> Optional[PaymentGateway]: """Get the active gateway for a provider""" query = PaymentGateway.query.filter_by(is_active=True) if provider: query = query.filter_by(provider=provider) return query.first() - + def process_payment( self, invoice_id: int, gateway_id: int, amount: Decimal, payment_method: str, - gateway_response: Optional[Dict[str, Any]] = None + gateway_response: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Process a payment through a gateway. - + Returns: dict with 'success', 'message', and 'transaction' keys """ try: invoice = Invoice.query.get(invoice_id) if not invoice: - return { - 'success': False, - 'message': 'Invoice not found.' - } - + return {"success": False, "message": "Invoice not found."} + gateway = PaymentGateway.query.get(gateway_id) if not gateway or not gateway.is_active: - return { - 'success': False, - 'message': 'Payment gateway not found or inactive.' - } - + return {"success": False, "message": "Payment gateway not found or inactive."} + # Generate transaction ID (will be replaced by gateway response) transaction_id = f"{gateway.provider}_{invoice_id}_{int(now_in_app_timezone().timestamp())}" - + # Create transaction record transaction = PaymentTransaction( invoice_id=invoice_id, @@ -131,137 +105,116 @@ def process_payment( transaction_id=transaction_id, amount=amount, currency=invoice.currency_code, - status='processing', + status="processing", payment_method=payment_method, - gateway_response=gateway_response + gateway_response=gateway_response, ) - + db.session.add(transaction) - + # Update invoice payment status - invoice.amount_paid = (invoice.amount_paid or Decimal('0')) + amount + invoice.amount_paid = (invoice.amount_paid or Decimal("0")) + amount if invoice.amount_paid >= invoice.total_amount: - invoice.payment_status = 'fully_paid' - invoice.status = 'paid' + invoice.payment_status = "fully_paid" + invoice.status = "paid" invoice.payment_date = now_in_app_timezone().date() - elif invoice.amount_paid > Decimal('0'): - invoice.payment_status = 'partially_paid' - - if not safe_commit('process_payment', {'invoice_id': invoice_id}): - return { - 'success': False, - 'message': 'Could not process payment due to a database error.' - } - - emit_event(WebhookEvent.PAYMENT_PROCESSED, { - 'invoice_id': invoice_id, - 'transaction_id': transaction.id, - 'amount': float(amount), - 'gateway': gateway.provider - }) - - return { - 'success': True, - 'message': 'Payment processed successfully.', - 'transaction': transaction - } + elif invoice.amount_paid > Decimal("0"): + invoice.payment_status = "partially_paid" + + if not safe_commit("process_payment", {"invoice_id": invoice_id}): + return {"success": False, "message": "Could not process payment due to a database error."} + + emit_event( + WebhookEvent.PAYMENT_PROCESSED, + { + "invoice_id": invoice_id, + "transaction_id": transaction.id, + "amount": float(amount), + "gateway": gateway.provider, + }, + ) + + return {"success": True, "message": "Payment processed successfully.", "transaction": transaction} except Exception as e: db.session.rollback() logger.error(f"Error processing payment: {e}") - return { - 'success': False, - 'message': f'Error processing payment: {str(e)}' - } - + return {"success": False, "message": f"Error processing payment: {str(e)}"} + def update_transaction_status( self, transaction_id: str, status: str, gateway_response: Optional[Dict[str, Any]] = None, error_message: Optional[str] = None, - error_code: Optional[str] = None + error_code: Optional[str] = None, ) -> Dict[str, Any]: """ Update a payment transaction status (typically from webhook). - + Returns: dict with 'success', 'message', and 'transaction' keys """ try: - transaction = PaymentTransaction.query.filter_by( - transaction_id=transaction_id - ).first() - + transaction = PaymentTransaction.query.filter_by(transaction_id=transaction_id).first() + if not transaction: - return { - 'success': False, - 'message': 'Transaction not found.' - } - + return {"success": False, "message": "Transaction not found."} + old_status = transaction.status transaction.status = status transaction.processed_at = now_in_app_timezone() - + if gateway_response: transaction.gateway_response = gateway_response # Extract gateway fee and net amount if available - if 'fee' in gateway_response: - transaction.gateway_fee = Decimal(str(gateway_response['fee'])) - if 'net_amount' in gateway_response: - transaction.net_amount = Decimal(str(gateway_response['net_amount'])) - + if "fee" in gateway_response: + transaction.gateway_fee = Decimal(str(gateway_response["fee"])) + if "net_amount" in gateway_response: + transaction.net_amount = Decimal(str(gateway_response["net_amount"])) + if error_message: transaction.error_message = error_message if error_code: transaction.error_code = error_code - + # Update invoice if payment completed or failed - if status == 'completed' and old_status != 'completed': + if status == "completed" and old_status != "completed": invoice = Invoice.query.get(transaction.invoice_id) if invoice: - invoice.amount_paid = (invoice.amount_paid or Decimal('0')) + transaction.amount + invoice.amount_paid = (invoice.amount_paid or Decimal("0")) + transaction.amount if invoice.amount_paid >= invoice.total_amount: - invoice.payment_status = 'fully_paid' - invoice.status = 'paid' + invoice.payment_status = "fully_paid" + invoice.status = "paid" invoice.payment_date = now_in_app_timezone().date() - - if not safe_commit('update_transaction_status', {'transaction_id': transaction_id}): - return { - 'success': False, - 'message': 'Could not update transaction due to a database error.' - } - - if status == 'completed': - emit_event(WebhookEvent.PAYMENT_PROCESSED, { - 'invoice_id': transaction.invoice_id, - 'transaction_id': transaction.id, - 'amount': float(transaction.amount) - }) - elif status == 'failed': - emit_event(WebhookEvent.PAYMENT_FAILED, { - 'invoice_id': transaction.invoice_id, - 'transaction_id': transaction.id, - 'error': error_message - }) - - return { - 'success': True, - 'message': 'Transaction updated successfully.', - 'transaction': transaction - } + + if not safe_commit("update_transaction_status", {"transaction_id": transaction_id}): + return {"success": False, "message": "Could not update transaction due to a database error."} + + if status == "completed": + emit_event( + WebhookEvent.PAYMENT_PROCESSED, + { + "invoice_id": transaction.invoice_id, + "transaction_id": transaction.id, + "amount": float(transaction.amount), + }, + ) + elif status == "failed": + emit_event( + WebhookEvent.PAYMENT_FAILED, + {"invoice_id": transaction.invoice_id, "transaction_id": transaction.id, "error": error_message}, + ) + + return {"success": True, "message": "Transaction updated successfully.", "transaction": transaction} except Exception as e: db.session.rollback() logger.error(f"Error updating transaction: {e}") - return { - 'success': False, - 'message': f'Error updating transaction: {str(e)}' - } - + return {"success": False, "message": f"Error updating transaction: {str(e)}"} + def get_transaction(self, transaction_id: int) -> Optional[PaymentTransaction]: """Get a transaction by ID""" return PaymentTransaction.query.get(transaction_id) - + def get_invoice_transactions(self, invoice_id: int) -> List[PaymentTransaction]: """Get all transactions for an invoice""" return PaymentTransaction.query.filter_by(invoice_id=invoice_id).all() - diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 1c0e7add..c329f9d3 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -15,11 +15,11 @@ class PaymentService: """Service for payment operations""" - + def __init__(self): self.payment_repo = PaymentRepository() self.invoice_repo = InvoiceRepository() - + def create_payment( self, invoice_id: int, @@ -30,37 +30,29 @@ def create_payment( method: Optional[str] = None, reference: Optional[str] = None, notes: Optional[str] = None, - status: str = 'completed', + status: str = "completed", gateway_transaction_id: Optional[str] = None, - gateway_fee: Optional[Decimal] = None + gateway_fee: Optional[Decimal] = None, ) -> Dict[str, Any]: """ Create a new payment. - + Returns: dict with 'success', 'message', and 'payment' keys """ # Validate invoice invoice = self.invoice_repo.get_by_id(invoice_id) if not invoice: - return { - 'success': False, - 'message': 'Invoice not found', - 'error': 'invalid_invoice' - } - + return {"success": False, "message": "Invoice not found", "error": "invalid_invoice"} + # Validate amount if amount <= 0: - return { - 'success': False, - 'message': 'Amount must be greater than zero', - 'error': 'invalid_amount' - } - + return {"success": False, "message": "Amount must be greater than zero", "error": "invalid_amount"} + # Get currency from invoice if not provided if not currency: currency = invoice.currency_code - + # Create payment payment = self.payment_repo.create( invoice_id=invoice_id, @@ -73,50 +65,41 @@ def create_payment( status=status, received_by=received_by, gateway_transaction_id=gateway_transaction_id, - gateway_fee=gateway_fee + gateway_fee=gateway_fee, ) - + # Calculate net amount payment.calculate_net_amount() - + # Update invoice payment status if payment is completed - if status == 'completed': + if status == "completed": total_payments = self.payment_repo.get_total_for_invoice(invoice_id) invoice.amount_paid = total_payments + amount - + # Update payment status if invoice.amount_paid >= invoice.total_amount: - invoice.payment_status = 'fully_paid' + invoice.payment_status = "fully_paid" elif invoice.amount_paid > 0: - invoice.payment_status = 'partially_paid' + invoice.payment_status = "partially_paid" else: - invoice.payment_status = 'unpaid' - - if not safe_commit('create_payment', {'invoice_id': invoice_id, 'received_by': received_by}): + invoice.payment_status = "unpaid" + + if not safe_commit("create_payment", {"invoice_id": invoice_id, "received_by": received_by}): return { - 'success': False, - 'message': 'Could not create payment due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create payment due to a database error", + "error": "database_error", } - + # Emit domain event - emit_event('payment.created', { - 'payment_id': payment.id, - 'invoice_id': invoice_id, - 'amount': float(amount) - }) - - return { - 'success': True, - 'message': 'Payment created successfully', - 'payment': payment - } - + emit_event("payment.created", {"payment_id": payment.id, "invoice_id": invoice_id, "amount": float(amount)}) + + return {"success": True, "message": "Payment created successfully", "payment": payment} + def get_invoice_payments(self, invoice_id: int) -> List[Payment]: """Get all payments for an invoice""" return self.payment_repo.get_by_invoice(invoice_id, include_relations=True) - + def get_total_paid(self, invoice_id: int) -> Decimal: """Get total amount paid for an invoice""" return self.payment_repo.get_total_for_invoice(invoice_id) - diff --git a/app/services/permission_service.py b/app/services/permission_service.py index e2b01a58..d6183e26 100644 --- a/app/services/permission_service.py +++ b/app/services/permission_service.py @@ -11,153 +11,110 @@ class PermissionService: """Service for permission operations""" - + def __init__(self): self.user_repo = UserRepository() - - def check_permission( - self, - user_id: int, - permission_name: str - ) -> bool: + + def check_permission(self, user_id: int, permission_name: str) -> bool: """ Check if a user has a specific permission. - + Returns: True if user has permission, False otherwise """ user = self.user_repo.get_by_id(user_id) - + if not user: return False - + # Admins have all permissions - if user.role == 'admin': + if user.role == "admin": return True - + # Check role permissions role = Role.query.filter_by(name=user.role).first() if role: - permission = Permission.query.filter_by( - name=permission_name, - role_id=role.id - ).first() + permission = Permission.query.filter_by(name=permission_name, role_id=role.id).first() if permission and permission.granted: return True - + return False - - def grant_permission( - self, - role_name: str, - permission_name: str - ) -> Dict[str, Any]: + + def grant_permission(self, role_name: str, permission_name: str) -> Dict[str, Any]: """ Grant a permission to a role. - + Returns: dict with 'success' and 'message' keys """ role = Role.query.filter_by(name=role_name).first() if not role: - return { - 'success': False, - 'message': 'Role not found', - 'error': 'invalid_role' - } - + return {"success": False, "message": "Role not found", "error": "invalid_role"} + # Check if permission already exists - permission = Permission.query.filter_by( - name=permission_name, - role_id=role.id - ).first() - + permission = Permission.query.filter_by(name=permission_name, role_id=role.id).first() + if permission: permission.granted = True else: - permission = Permission( - name=permission_name, - role_id=role.id, - granted=True - ) + permission = Permission(name=permission_name, role_id=role.id, granted=True) db.session.add(permission) - - if not safe_commit('grant_permission', {'role': role_name, 'permission': permission_name}): + + if not safe_commit("grant_permission", {"role": role_name, "permission": permission_name}): return { - 'success': False, - 'message': 'Could not grant permission due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not grant permission due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Permission granted successfully' - } - - def revoke_permission( - self, - role_name: str, - permission_name: str - ) -> Dict[str, Any]: + + return {"success": True, "message": "Permission granted successfully"} + + def revoke_permission(self, role_name: str, permission_name: str) -> Dict[str, Any]: """ Revoke a permission from a role. - + Returns: dict with 'success' and 'message' keys """ role = Role.query.filter_by(name=role_name).first() if not role: - return { - 'success': False, - 'message': 'Role not found', - 'error': 'invalid_role' - } - - permission = Permission.query.filter_by( - name=permission_name, - role_id=role.id - ).first() - + return {"success": False, "message": "Role not found", "error": "invalid_role"} + + permission = Permission.query.filter_by(name=permission_name, role_id=role.id).first() + if permission: permission.granted = False - - if not safe_commit('revoke_permission', {'role': role_name, 'permission': permission_name}): + + if not safe_commit("revoke_permission", {"role": role_name, "permission": permission_name}): return { - 'success': False, - 'message': 'Could not revoke permission due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not revoke permission due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Permission revoked successfully' - } - + + return {"success": True, "message": "Permission revoked successfully"} + def get_user_permissions(self, user_id: int) -> List[str]: """ Get all permissions for a user. - + Returns: List of permission names """ user = self.user_repo.get_by_id(user_id) - + if not user: return [] - + # Admins have all permissions - if user.role == 'admin': - return ['admin:all'] - + if user.role == "admin": + return ["admin:all"] + # Get role permissions role = Role.query.filter_by(name=user.role).first() if not role: return [] - - permissions = Permission.query.filter_by( - role_id=role.id, - granted=True - ).all() - - return [p.name for p in permissions] + permissions = Permission.query.filter_by(role_id=role.id, granted=True).all() + + return [p.name for p in permissions] diff --git a/app/services/project_service.py b/app/services/project_service.py index 474e7d46..f8d88049 100644 --- a/app/services/project_service.py +++ b/app/services/project_service.py @@ -15,16 +15,16 @@ class ProjectService: """ Service for project business logic operations. - + This service handles all project-related business logic including: - Creating and updating projects - Listing projects with filtering and pagination - Getting project details with related data - Archiving projects - + All methods use the repository pattern for data access and include eager loading to prevent N+1 query problems. - + Example: service = ProjectService() result = service.create_project( @@ -35,14 +35,14 @@ class ProjectService: if result['success']: project = result['project'] """ - + def __init__(self): """ Initialize ProjectService with required repositories. """ self.project_repo = ProjectRepository() self.client_repo = ClientRepository() - + def create_project( self, name: str, @@ -50,32 +50,28 @@ def create_project( created_by: int, description: Optional[str] = None, billable: bool = True, - hourly_rate: Optional[float] = None + hourly_rate: Optional[float] = None, ) -> Dict[str, Any]: """ Create a new project. - + Returns: dict with 'success', 'message', and 'project' keys """ # Validate client client = self.client_repo.get_by_id(client_id) if not client: - return { - 'success': False, - 'message': 'Invalid client', - 'error': 'invalid_client' - } - + return {"success": False, "message": "Invalid client", "error": "invalid_client"} + # Check for duplicate name existing = self.project_repo.find_one_by(name=name, client_id=client_id) if existing: return { - 'success': False, - 'message': 'A project with this name already exists for this client', - 'error': 'duplicate_project' + "success": False, + "message": "A project with this name already exists for this client", + "error": "duplicate_project", } - + # Create project project = self.project_repo.create( name=name, @@ -84,143 +80,106 @@ def create_project( billable=billable, hourly_rate=hourly_rate, status=ProjectStatus.ACTIVE.value, - created_by=created_by + created_by=created_by, ) - - if not safe_commit('create_project', {'client_id': client_id, 'name': name}): + + if not safe_commit("create_project", {"client_id": client_id, "name": name}): return { - 'success': False, - 'message': 'Could not create project due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create project due to a database error", + "error": "database_error", } - + # Emit domain event - emit_event(WebhookEvent.PROJECT_CREATED.value, { - 'project_id': project.id, - 'client_id': client_id - }) - - return { - 'success': True, - 'message': 'Project created successfully', - 'project': project - } - - def update_project( - self, - project_id: int, - user_id: int, - **kwargs - ) -> Dict[str, Any]: + emit_event(WebhookEvent.PROJECT_CREATED.value, {"project_id": project.id, "client_id": client_id}) + + return {"success": True, "message": "Project created successfully", "project": project} + + def update_project(self, project_id: int, user_id: int, **kwargs) -> Dict[str, Any]: """ Update a project. - + Returns: dict with 'success', 'message', and 'project' keys """ project = self.project_repo.get_by_id(project_id) - + if not project: - return { - 'success': False, - 'message': 'Project not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Project not found", "error": "not_found"} + # Update fields self.project_repo.update(project, **kwargs) - - if not safe_commit('update_project', {'project_id': project_id, 'user_id': user_id}): + + if not safe_commit("update_project", {"project_id": project_id, "user_id": user_id}): return { - 'success': False, - 'message': 'Could not update project due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not update project due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Project updated successfully', - 'project': project - } - - def archive_project( - self, - project_id: int, - user_id: int, - reason: Optional[str] = None - ) -> Dict[str, Any]: + + return {"success": True, "message": "Project updated successfully", "project": project} + + def archive_project(self, project_id: int, user_id: int, reason: Optional[str] = None) -> Dict[str, Any]: """ Archive a project. - + Returns: dict with 'success', 'message', and 'project' keys """ project = self.project_repo.archive(project_id, user_id, reason) - + if not project: + return {"success": False, "message": "Project not found", "error": "not_found"} + + if not safe_commit("archive_project", {"project_id": project_id, "user_id": user_id}): return { - 'success': False, - 'message': 'Project not found', - 'error': 'not_found' - } - - if not safe_commit('archive_project', {'project_id': project_id, 'user_id': user_id}): - return { - 'success': False, - 'message': 'Could not archive project due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not archive project due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Project archived successfully', - 'project': project - } - + + return {"success": True, "message": "Project archived successfully", "project": project} + def get_active_projects(self, user_id: Optional[int] = None, client_id: Optional[int] = None) -> List[Project]: """Get active projects with optional filters""" - return self.project_repo.get_active_projects( - user_id=user_id, - client_id=client_id, - include_relations=True - ) - + return self.project_repo.get_active_projects(user_id=user_id, client_id=client_id, include_relations=True) + def get_project_with_details( self, project_id: int, include_time_entries: bool = True, include_tasks: bool = True, include_comments: bool = True, - include_costs: bool = True + include_costs: bool = True, ) -> Optional[Project]: """ Get project with all related data using eager loading to prevent N+1 queries. - + Args: project_id: The project ID include_time_entries: Whether to include time entries include_tasks: Whether to include tasks include_comments: Whether to include comments include_costs: Whether to include costs - + Returns: Project with eagerly loaded relations, or None if not found """ from sqlalchemy.orm import joinedload from app.models import Task, Comment, ProjectCost - + query = self.project_repo.query().filter_by(id=project_id) - + # Eagerly load client (client_obj is not dynamic, so it can be eagerly loaded) query = query.options(joinedload(Project.client_obj)) - + # Note: time_entries, tasks, costs, and comments are dynamic relationships # (lazy='dynamic'), so they cannot be eagerly loaded with joinedload(). # They return query objects that can be filtered and accessed when needed. # We'll query them separately when needed instead. - + return query.first() - + def list_projects( self, status: Optional[str] = None, @@ -229,131 +188,113 @@ def list_projects( favorites_only: bool = False, user_id: Optional[int] = None, page: int = 1, - per_page: int = 20 + per_page: int = 20, ) -> Dict[str, Any]: """ List projects with filtering and pagination. Uses eager loading to prevent N+1 queries. - + Returns: dict with 'projects', 'pagination', and 'total' keys """ from sqlalchemy.orm import joinedload from app.models import UserFavoriteProject, Client - + query = self.project_repo.query() - + # Eagerly load client to prevent N+1 query = query.options(joinedload(Project.client_obj)) - + # Filter by favorites if requested if favorites_only and user_id: query = query.join( UserFavoriteProject, - db.and_( - UserFavoriteProject.project_id == Project.id, - UserFavoriteProject.user_id == user_id - ) + db.and_(UserFavoriteProject.project_id == Project.id, UserFavoriteProject.user_id == user_id), ) - + # Filter by status if status: query = query.filter(Project.status == status) - + # Filter by client name if client_name: query = query.join(Client).filter(Client.name == client_name) - + # Search filter if search: like = f"%{search}%" - query = query.filter( - db.or_( - Project.name.ilike(like), - Project.description.ilike(like) - ) - ) - + query = query.filter(db.or_(Project.name.ilike(like), Project.description.ilike(like))) + # Order and paginate query = query.order_by(Project.name) pagination = query.paginate(page=page, per_page=per_page, error_out=False) - - return { - 'projects': pagination.items, - 'pagination': pagination, - 'total': pagination.total - } - + + return {"projects": pagination.items, "pagination": pagination, "total": pagination.total} + def get_project_view_data( - self, - project_id: int, - time_entries_page: int = 1, - time_entries_per_page: int = 50 + self, project_id: int, time_entries_page: int = 1, time_entries_per_page: int = 50 ) -> Dict[str, Any]: """ Get all data needed for project view page. Uses eager loading to prevent N+1 queries. - + Returns: - dict with 'project', 'time_entries_pagination', 'tasks', 'comments', + dict with 'project', 'time_entries_pagination', 'tasks', 'comments', 'recent_costs', 'total_costs_count', 'user_totals', 'kanban_columns' """ from sqlalchemy.orm import joinedload from app.models import Task, Comment, ProjectCost, KanbanColumn from app.repositories import TimeEntryRepository - + # Get project with eager loading project = self.get_project_with_details( project_id=project_id, include_time_entries=True, include_tasks=True, include_comments=True, - include_costs=True + include_costs=True, ) - + if not project: - return { - 'success': False, - 'message': 'Project not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Project not found", "error": "not_found"} + # Get time entries with pagination and eager loading time_entry_repo = TimeEntryRepository() - entries_query = time_entry_repo.query().filter( - TimeEntry.project_id == project_id, - TimeEntry.end_time.isnot(None) - ).options( - joinedload(TimeEntry.user), - joinedload(TimeEntry.task) - ).order_by(TimeEntry.start_time.desc()) - + entries_query = ( + time_entry_repo.query() + .filter(TimeEntry.project_id == project_id, TimeEntry.end_time.isnot(None)) + .options(joinedload(TimeEntry.user), joinedload(TimeEntry.task)) + .order_by(TimeEntry.start_time.desc()) + ) + entries_pagination = entries_query.paginate( - page=time_entries_page, - per_page=time_entries_per_page, - error_out=False + page=time_entries_page, per_page=time_entries_per_page, error_out=False ) - + # Get tasks with eager loading (already loaded but need to order) - tasks = Task.query.filter_by(project_id=project_id).options( - joinedload(Task.assigned_user) - ).order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() - + tasks = ( + Task.query.filter_by(project_id=project_id) + .options(joinedload(Task.assigned_user)) + .order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()) + .all() + ) + # Get comments (already loaded via relationship) from app.models import Comment + comments = Comment.get_project_comments(project_id, include_replies=True) - + # Get recent costs (already loaded but need to order) - recent_costs = ProjectCost.query.filter_by(project_id=project_id).order_by( - ProjectCost.cost_date.desc() - ).limit(5).all() - + recent_costs = ( + ProjectCost.query.filter_by(project_id=project_id).order_by(ProjectCost.cost_date.desc()).limit(5).all() + ) + # Get total cost count total_costs_count = ProjectCost.query.filter_by(project_id=project_id).count() - + # Get user totals user_totals = project.get_user_totals() - + # Get kanban columns kanban_columns = [] if KanbanColumn: @@ -363,16 +304,15 @@ def get_project_view_data( if not kanban_columns: KanbanColumn.initialize_default_columns(project_id=None) kanban_columns = KanbanColumn.get_active_columns(project_id=None) - + return { - 'success': True, - 'project': project, - 'time_entries_pagination': entries_pagination, - 'tasks': tasks, - 'comments': comments, - 'recent_costs': recent_costs, - 'total_costs_count': total_costs_count, - 'user_totals': user_totals, - 'kanban_columns': kanban_columns + "success": True, + "project": project, + "time_entries_pagination": entries_pagination, + "tasks": tasks, + "comments": comments, + "recent_costs": recent_costs, + "total_costs_count": total_costs_count, + "user_totals": user_totals, + "kanban_columns": kanban_columns, } - diff --git a/app/services/project_template_service.py b/app/services/project_template_service.py index 7f948317..ec07f2ed 100644 --- a/app/services/project_template_service.py +++ b/app/services/project_template_service.py @@ -14,7 +14,7 @@ class ProjectTemplateService: """ Service for project template operations. """ - + def create_template( self, name: str, @@ -24,11 +24,11 @@ def create_template( tasks: Optional[List[Dict[str, Any]]] = None, category: Optional[str] = None, tags: Optional[List[str]] = None, - is_public: bool = False + is_public: bool = False, ) -> Dict[str, Any]: """ Create a new project template. - + Returns: dict with 'success', 'message', and 'template' keys """ @@ -41,285 +41,216 @@ def create_template( category=category, tags=tags or [], is_public=is_public, - created_by=created_by + created_by=created_by, ) - + db.session.add(template) - if not safe_commit('create_template', {'name': name}): - return { - 'success': False, - 'message': 'Could not create template due to a database error.' - } - - emit_event(WebhookEvent.PROJECT_TEMPLATE_CREATED, { - 'template_id': template.id, - 'template_name': template.name, - 'created_by': created_by - }) - - return { - 'success': True, - 'message': 'Template created successfully.', - 'template': template - } + if not safe_commit("create_template", {"name": name}): + return {"success": False, "message": "Could not create template due to a database error."} + + emit_event( + WebhookEvent.PROJECT_TEMPLATE_CREATED, + {"template_id": template.id, "template_name": template.name, "created_by": created_by}, + ) + + return {"success": True, "message": "Template created successfully.", "template": template} except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error creating template: {str(e)}' - } - + return {"success": False, "message": f"Error creating template: {str(e)}"} + def create_project_from_template( self, template_id: int, client_id: int, created_by: int, name: Optional[str] = None, - override_config: Optional[Dict[str, Any]] = None + override_config: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: """ Create a project from a template. - + Returns: dict with 'success', 'message', and 'project' keys """ try: template = ProjectTemplate.query.get(template_id) if not template: - return { - 'success': False, - 'message': 'Template not found.' - } - + return {"success": False, "message": "Template not found."} + # Merge template config with overrides config = template.config.copy() if override_config: config.update(override_config) - + # Use provided name or template name project_name = name or template.name - + # Create project from app.services.project_service import ProjectService + project_service = ProjectService() - + result = project_service.create_project( name=project_name, client_id=client_id, - description=config.get('description', template.description), - billable=config.get('billable', True), - hourly_rate=config.get('hourly_rate'), - created_by=created_by + description=config.get("description", template.description), + billable=config.get("billable", True), + hourly_rate=config.get("hourly_rate"), + created_by=created_by, ) - - if not result['success']: + + if not result["success"]: return result - - project = result['project'] - + + project = result["project"] + # Apply additional config - if 'billing_ref' in config: - project.billing_ref = config['billing_ref'] - if 'code' in config: - project.code = config['code'] - if 'estimated_hours' in config: - project.estimated_hours = config['estimated_hours'] - if 'budget_amount' in config: - project.budget_amount = config['budget_amount'] - if 'budget_threshold_percent' in config: - project.budget_threshold_percent = config['budget_threshold_percent'] - + if "billing_ref" in config: + project.billing_ref = config["billing_ref"] + if "code" in config: + project.code = config["code"] + if "estimated_hours" in config: + project.estimated_hours = config["estimated_hours"] + if "budget_amount" in config: + project.budget_amount = config["budget_amount"] + if "budget_threshold_percent" in config: + project.budget_threshold_percent = config["budget_threshold_percent"] + # Create tasks from template if template.tasks: from app.services.task_service import TaskService + task_service = TaskService() - + for task_config in template.tasks: task_service.create_task( - name=task_config.get('name', 'Untitled Task'), + name=task_config.get("name", "Untitled Task"), project_id=project.id, - description=task_config.get('description'), - priority=task_config.get('priority', 'medium'), - status=task_config.get('status', 'todo'), - estimated_hours=task_config.get('estimated_hours'), - created_by=created_by + description=task_config.get("description"), + priority=task_config.get("priority", "medium"), + status=task_config.get("status", "todo"), + estimated_hours=task_config.get("estimated_hours"), + created_by=created_by, ) - + # Update template usage template.usage_count += 1 from app.utils.timezone import now_in_app_timezone + template.last_used_at = now_in_app_timezone() db.session.commit() - - return { - 'success': True, - 'message': 'Project created from template successfully.', - 'project': project - } + + return {"success": True, "message": "Project created from template successfully.", "project": project} except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error creating project from template: {str(e)}' - } - + return {"success": False, "message": f"Error creating project from template: {str(e)}"} + def get_template(self, template_id: int) -> Optional[ProjectTemplate]: """Get a template by ID""" return ProjectTemplate.query.get(template_id) - + def list_templates( self, user_id: Optional[int] = None, category: Optional[str] = None, is_public: Optional[bool] = None, page: int = 1, - per_page: int = 20 + per_page: int = 20, ) -> Any: # Returns pagination object from query.paginate() """ List templates with filtering and pagination. - + Returns: Pagination object with templates """ query = ProjectTemplate.query - + # Filter by user (own templates or public) if user_id: - query = query.filter( - db.or_( - ProjectTemplate.created_by == user_id, - ProjectTemplate.is_public == True - ) - ) + query = query.filter(db.or_(ProjectTemplate.created_by == user_id, ProjectTemplate.is_public == True)) elif is_public is not None: query = query.filter(ProjectTemplate.is_public == is_public) - + # Filter by category if category: query = query.filter(ProjectTemplate.category == category) - + # Order by usage count and name - query = query.order_by( - ProjectTemplate.usage_count.desc(), - ProjectTemplate.name.asc() - ) - + query = query.order_by(ProjectTemplate.usage_count.desc(), ProjectTemplate.name.asc()) + return query.paginate(page=page, per_page=per_page, error_out=False) - - def update_template( - self, - template_id: int, - user_id: int, - **kwargs - ) -> Dict[str, Any]: + + def update_template(self, template_id: int, user_id: int, **kwargs) -> Dict[str, Any]: """ Update a template. - + Returns: dict with 'success' and 'message' keys """ try: template = ProjectTemplate.query.get(template_id) if not template: - return { - 'success': False, - 'message': 'Template not found.' - } - + return {"success": False, "message": "Template not found."} + # Check permissions if template.created_by != user_id: - return { - 'success': False, - 'message': 'You do not have permission to edit this template.' - } - + return {"success": False, "message": "You do not have permission to edit this template."} + # Update fields - if 'name' in kwargs: - template.name = kwargs['name'] - if 'description' in kwargs: - template.description = kwargs['description'] - if 'config' in kwargs: - template.config = kwargs['config'] - if 'tasks' in kwargs: - template.tasks = kwargs['tasks'] - if 'category' in kwargs: - template.category = kwargs['category'] - if 'tags' in kwargs: - template.tags = kwargs['tags'] - if 'is_public' in kwargs: - template.is_public = kwargs['is_public'] - - if not safe_commit('update_template', {'template_id': template_id}): - return { - 'success': False, - 'message': 'Could not update template due to a database error.' - } - - emit_event(WebhookEvent.PROJECT_TEMPLATE_UPDATED, { - 'template_id': template.id, - 'template_name': template.name - }) - - return { - 'success': True, - 'message': 'Template updated successfully.', - 'template': template - } + if "name" in kwargs: + template.name = kwargs["name"] + if "description" in kwargs: + template.description = kwargs["description"] + if "config" in kwargs: + template.config = kwargs["config"] + if "tasks" in kwargs: + template.tasks = kwargs["tasks"] + if "category" in kwargs: + template.category = kwargs["category"] + if "tags" in kwargs: + template.tags = kwargs["tags"] + if "is_public" in kwargs: + template.is_public = kwargs["is_public"] + + if not safe_commit("update_template", {"template_id": template_id}): + return {"success": False, "message": "Could not update template due to a database error."} + + emit_event( + WebhookEvent.PROJECT_TEMPLATE_UPDATED, {"template_id": template.id, "template_name": template.name} + ) + + return {"success": True, "message": "Template updated successfully.", "template": template} except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error updating template: {str(e)}' - } - - def delete_template( - self, - template_id: int, - user_id: int - ) -> Dict[str, Any]: + return {"success": False, "message": f"Error updating template: {str(e)}"} + + def delete_template(self, template_id: int, user_id: int) -> Dict[str, Any]: """ Delete a template. - + Returns: dict with 'success' and 'message' keys """ try: template = ProjectTemplate.query.get(template_id) if not template: - return { - 'success': False, - 'message': 'Template not found.' - } - + return {"success": False, "message": "Template not found."} + # Check permissions if template.created_by != user_id: - return { - 'success': False, - 'message': 'You do not have permission to delete this template.' - } - + return {"success": False, "message": "You do not have permission to delete this template."} + template_name = template.name db.session.delete(template) - - if not safe_commit('delete_template', {'template_id': template_id}): - return { - 'success': False, - 'message': 'Could not delete template due to a database error.' - } - - emit_event(WebhookEvent.PROJECT_TEMPLATE_DELETED, { - 'template_id': template_id, - 'template_name': template_name - }) - - return { - 'success': True, - 'message': 'Template deleted successfully.' - } + + if not safe_commit("delete_template", {"template_id": template_id}): + return {"success": False, "message": "Could not delete template due to a database error."} + + emit_event( + WebhookEvent.PROJECT_TEMPLATE_DELETED, {"template_id": template_id, "template_name": template_name} + ) + + return {"success": True, "message": "Template deleted successfully."} except Exception as e: db.session.rollback() - return { - 'success': False, - 'message': f'Error deleting template: {str(e)}' - } - + return {"success": False, "message": f"Error deleting template: {str(e)}"} diff --git a/app/services/reporting_service.py b/app/services/reporting_service.py index 03899bad..1907c745 100644 --- a/app/services/reporting_service.py +++ b/app/services/reporting_service.py @@ -28,29 +28,29 @@ class ReportingService: """ Service for reporting and analytics operations. - + Provides comprehensive reporting capabilities with optimized queries and aggregated statistics. """ - + def __init__(self): """Initialize ReportingService with required repositories.""" self.time_entry_repo = TimeEntryRepository() self.project_repo = ProjectRepository() self.invoice_repo = InvoiceRepository() self.expense_repo = ExpenseRepository() - + def get_time_summary( self, user_id: Optional[int] = None, project_id: Optional[int] = None, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None, - billable_only: bool = False + billable_only: bool = False, ) -> Dict[str, Any]: """ Get time tracking summary. - + Returns: dict with total hours, billable hours, entries count, etc. """ @@ -58,59 +58,47 @@ def get_time_summary( start_date = datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) if not end_date: end_date = datetime.now() - + # Get total duration total_seconds = self.time_entry_repo.get_total_duration( user_id=user_id, project_id=project_id, start_date=start_date, end_date=end_date, - billable_only=billable_only + billable_only=billable_only, ) - + total_hours = total_seconds / 3600 - + # Get billable duration billable_seconds = self.time_entry_repo.get_total_duration( - user_id=user_id, - project_id=project_id, - start_date=start_date, - end_date=end_date, - billable_only=True + user_id=user_id, project_id=project_id, start_date=start_date, end_date=end_date, billable_only=True ) billable_hours = billable_seconds / 3600 - + # Get entries entries = self.time_entry_repo.get_by_date_range( - start_date=start_date, - end_date=end_date, - user_id=user_id, - project_id=project_id, - include_relations=False + start_date=start_date, end_date=end_date, user_id=user_id, project_id=project_id, include_relations=False ) - + return { - 'total_hours': round(total_hours, 2), - 'billable_hours': round(billable_hours, 2), - 'non_billable_hours': round(total_hours - billable_hours, 2), - 'total_entries': len(entries), - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat() + "total_hours": round(total_hours, 2), + "billable_hours": round(billable_hours, 2), + "non_billable_hours": round(total_hours - billable_hours, 2), + "total_entries": len(entries), + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), } - - def get_reports_summary( - self, - user_id: Optional[int] = None, - is_admin: bool = False - ) -> Dict[str, Any]: + + def get_reports_summary(self, user_id: Optional[int] = None, is_admin: bool = False) -> Dict[str, Any]: """ Get comprehensive reports summary for dashboard. Uses optimized queries to prevent N+1 problems. - + Args: user_id: User ID for filtering (non-admin users) is_admin: Whether user is admin - + Returns: dict with summary statistics including: - total_hours, billable_hours @@ -120,171 +108,153 @@ def get_reports_summary( - month-over-month comparison """ # Build base queries - totals_query = db.session.query(func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_time.isnot(None) - ) + totals_query = db.session.query(func.sum(TimeEntry.duration_seconds)).filter(TimeEntry.end_time.isnot(None)) billable_query = db.session.query(func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True + TimeEntry.end_time.isnot(None), TimeEntry.billable == True ) entries_query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)) - + # Apply user filter if not admin if not is_admin and user_id: totals_query = totals_query.filter(TimeEntry.user_id == user_id) billable_query = billable_query.filter(TimeEntry.user_id == user_id) entries_query = entries_query.filter(TimeEntry.user_id == user_id) - + total_seconds = totals_query.scalar() or 0 billable_seconds = billable_query.scalar() or 0 - + # Get payment statistics (last 30 days) payment_query = db.session.query( - func.sum(Payment.amount).label('total_payments'), - func.count(Payment.id).label('payment_count'), - func.sum(Payment.gateway_fee).label('total_fees') - ).filter( - Payment.payment_date >= datetime.utcnow() - timedelta(days=30), - Payment.status == 'completed' - ) - + func.sum(Payment.amount).label("total_payments"), + func.count(Payment.id).label("payment_count"), + func.sum(Payment.gateway_fee).label("total_fees"), + ).filter(Payment.payment_date >= datetime.utcnow() - timedelta(days=30), Payment.status == "completed") + if not is_admin and user_id: - payment_query = payment_query.join(Invoice).join(Project).join(TimeEntry).filter( - TimeEntry.user_id == user_id + payment_query = ( + payment_query.join(Invoice).join(Project).join(TimeEntry).filter(TimeEntry.user_id == user_id) ) - + payment_result = payment_query.first() - + # Get project and user counts - active_projects = Project.query.filter_by(status='active').count() + active_projects = Project.query.filter_by(status="active").count() total_users = User.query.filter_by(is_active=True).count() if is_admin else 1 - + summary = { - 'total_hours': round(total_seconds / 3600, 2), - 'billable_hours': round(billable_seconds / 3600, 2), - 'active_projects': active_projects, - 'total_users': total_users, - 'total_payments': float(payment_result.total_payments or 0) if payment_result else 0, - 'payment_count': payment_result.payment_count or 0 if payment_result else 0, - 'payment_fees': float(payment_result.total_fees or 0) if payment_result else 0, + "total_hours": round(total_seconds / 3600, 2), + "billable_hours": round(billable_seconds / 3600, 2), + "active_projects": active_projects, + "total_users": total_users, + "total_payments": float(payment_result.total_payments or 0) if payment_result else 0, + "payment_count": payment_result.payment_count or 0 if payment_result else 0, + "payment_fees": float(payment_result.total_fees or 0) if payment_result else 0, } - + # Get recent entries with eager loading from sqlalchemy.orm import joinedload - recent_entries = entries_query.options( - joinedload(TimeEntry.project), - joinedload(TimeEntry.user), - joinedload(TimeEntry.task) - ).order_by(TimeEntry.start_time.desc()).limit(10).all() - + + recent_entries = ( + entries_query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.user), joinedload(TimeEntry.task)) + .order_by(TimeEntry.start_time.desc()) + .limit(10) + .all() + ) + # Get comparison data for this month vs last month now = datetime.utcnow() this_month_start = datetime(now.year, now.month, 1) last_month_start = (this_month_start - timedelta(days=1)).replace(day=1) last_month_end = this_month_start - timedelta(seconds=1) - + # Get hours for this month this_month_query = db.session.query(func.sum(TimeEntry.duration_seconds)).filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= this_month_start, - TimeEntry.start_time <= now + TimeEntry.end_time.isnot(None), TimeEntry.start_time >= this_month_start, TimeEntry.start_time <= now ) if not is_admin and user_id: this_month_query = this_month_query.filter(TimeEntry.user_id == user_id) this_month_seconds = this_month_query.scalar() or 0 - + # Get hours for last month last_month_query = db.session.query(func.sum(TimeEntry.duration_seconds)).filter( TimeEntry.end_time.isnot(None), TimeEntry.start_time >= last_month_start, - TimeEntry.start_time <= last_month_end + TimeEntry.start_time <= last_month_end, ) if not is_admin and user_id: last_month_query = last_month_query.filter(TimeEntry.user_id == user_id) last_month_seconds = last_month_query.scalar() or 0 - + comparison = { - 'this_month': {'hours': round(this_month_seconds / 3600, 2)}, - 'last_month': {'hours': round(last_month_seconds / 3600, 2)}, - 'change': ((this_month_seconds - last_month_seconds) / last_month_seconds * 100) if last_month_seconds > 0 else 0 + "this_month": {"hours": round(this_month_seconds / 3600, 2)}, + "last_month": {"hours": round(last_month_seconds / 3600, 2)}, + "change": ( + ((this_month_seconds - last_month_seconds) / last_month_seconds * 100) if last_month_seconds > 0 else 0 + ), } - - return { - 'summary': summary, - 'recent_entries': recent_entries, - 'comparison': comparison - } - + + return {"summary": summary, "recent_entries": recent_entries, "comparison": comparison} + def get_project_summary( - self, - project_id: int, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + self, project_id: int, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> Dict[str, Any]: """ Get project summary with time, expenses, and invoices. - + Returns: dict with project statistics """ project = self.project_repo.get_by_id(project_id) if not project: - return {'error': 'Project not found'} - + return {"error": "Project not found"} + # Get time summary - time_summary = self.get_time_summary( - project_id=project_id, - start_date=start_date, - end_date=end_date - ) - + time_summary = self.get_time_summary(project_id=project_id, start_date=start_date, end_date=end_date) + # Get expenses expenses = self.expense_repo.get_by_project( project_id=project_id, start_date=start_date.date() if start_date else None, - end_date=end_date.date() if end_date else None + end_date=end_date.date() if end_date else None, ) total_expenses = sum(exp.amount for exp in expenses) - + # Get invoices invoices = self.invoice_repo.get_by_project(project_id) total_invoiced = sum(inv.total_amount for inv in invoices) - + # Calculate revenue - billable_hours = time_summary['billable_hours'] - hourly_rate = project.hourly_rate or Decimal('0') - potential_revenue = float(billable_hours * hourly_rate) - + billable_hours = time_summary["billable_hours"] + hourly_rate = float(project.hourly_rate or Decimal("0")) + potential_revenue = billable_hours * hourly_rate + return { - 'project_id': project_id, - 'project_name': project.name, - 'time': time_summary, - 'expenses': { - 'total': float(total_expenses), - 'count': len(expenses), - 'billable': sum(exp.amount for exp in expenses if exp.billable) + "project_id": project_id, + "project_name": project.name, + "time": time_summary, + "expenses": { + "total": float(total_expenses), + "count": len(expenses), + "billable": sum(exp.amount for exp in expenses if exp.billable), + }, + "invoices": { + "total": float(total_invoiced), + "count": len(invoices), + "paid": sum(inv.amount_paid or 0 for inv in invoices), }, - 'invoices': { - 'total': float(total_invoiced), - 'count': len(invoices), - 'paid': sum(inv.amount_paid or 0 for inv in invoices) + "revenue": { + "potential": potential_revenue, + "invoiced": float(total_invoiced), + "paid": sum(float(inv.amount_paid or 0) for inv in invoices), }, - 'revenue': { - 'potential': potential_revenue, - 'invoiced': float(total_invoiced), - 'paid': sum(float(inv.amount_paid or 0) for inv in invoices) - } } - + def get_user_productivity( - self, - user_id: int, - start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + self, user_id: int, start_date: Optional[datetime] = None, end_date: Optional[datetime] = None ) -> Dict[str, Any]: """ Get user productivity metrics. - + Returns: dict with productivity statistics """ @@ -292,22 +262,15 @@ def get_user_productivity( start_date = datetime.now() - timedelta(days=30) if not end_date: end_date = datetime.now() - + # Get time summary - time_summary = self.get_time_summary( - user_id=user_id, - start_date=start_date, - end_date=end_date - ) - + time_summary = self.get_time_summary(user_id=user_id, start_date=start_date, end_date=end_date) + # Get entries by project entries = self.time_entry_repo.get_by_date_range( - start_date=start_date, - end_date=end_date, - user_id=user_id, - include_relations=True + start_date=start_date, end_date=end_date, user_id=user_id, include_relations=True ) - + # Group by project project_hours = {} for entry in entries: @@ -315,22 +278,21 @@ def get_user_productivity( hours = (entry.duration_seconds or 0) / 3600 if project_id not in project_hours: project_hours[project_id] = { - 'project_id': project_id, - 'project_name': entry.project.name if entry.project else 'Unknown', - 'hours': 0, - 'entries': 0 + "project_id": project_id, + "project_name": entry.project.name if entry.project else "Unknown", + "hours": 0, + "entries": 0, } - project_hours[project_id]['hours'] += hours - project_hours[project_id]['entries'] += 1 - + project_hours[project_id]["hours"] += hours + project_hours[project_id]["entries"] += 1 + return { - 'user_id': user_id, - 'time_summary': time_summary, - 'projects': list(project_hours.values()), - 'period': { - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat(), - 'days': (end_date - start_date).days - } + "user_id": user_id, + "time_summary": time_summary, + "projects": list(project_hours.values()), + "period": { + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "days": (end_date - start_date).days, + }, } - diff --git a/app/services/scheduled_report_service.py b/app/services/scheduled_report_service.py index 44b8a42d..cf2e0700 100644 --- a/app/services/scheduled_report_service.py +++ b/app/services/scheduled_report_service.py @@ -20,11 +20,11 @@ class ScheduledReportService: """ Service for scheduled report operations. """ - + def __init__(self): """Initialize ScheduledReportService""" self.reporting_service = ReportingService() - + def create_schedule( self, saved_view_id: int, @@ -32,11 +32,11 @@ def create_schedule( cadence: str, created_by: int, cron: Optional[str] = None, - timezone: Optional[str] = None + timezone: Optional[str] = None, ) -> Dict[str, Any]: """ Create a scheduled report. - + Args: saved_view_id: ID of saved report view recipients: Comma-separated email addresses @@ -44,7 +44,7 @@ def create_schedule( created_by: User ID creating the schedule cron: Custom cron expression (if cadence is 'custom-cron') timezone: Timezone for scheduling - + Returns: dict with 'success', 'message', and 'schedule' keys """ @@ -52,14 +52,11 @@ def create_schedule( # Validate saved view exists saved_view = SavedReportView.query.get(saved_view_id) if not saved_view: - return { - 'success': False, - 'message': 'Saved report view not found.' - } - + return {"success": False, "message": "Saved report view not found."} + # Calculate next run time next_run_at = self._calculate_next_run(cadence, cron, timezone) - + schedule = ReportEmailSchedule( saved_view_id=saved_view_id, recipients=recipients, @@ -68,181 +65,144 @@ def create_schedule( timezone=timezone, next_run_at=next_run_at, active=True, - created_by=created_by + created_by=created_by, ) - + db.session.add(schedule) if not db.session.commit(): - return { - 'success': False, - 'message': 'Could not create schedule due to a database error.' - } - - return { - 'success': True, - 'message': 'Scheduled report created successfully.', - 'schedule': schedule - } + return {"success": False, "message": "Could not create schedule due to a database error."} + + return {"success": True, "message": "Scheduled report created successfully.", "schedule": schedule} except Exception as e: db.session.rollback() logger.error(f"Error creating scheduled report: {e}") - return { - 'success': False, - 'message': f'Error creating schedule: {str(e)}' - } - + return {"success": False, "message": f"Error creating schedule: {str(e)}"} + def generate_and_send_report(self, schedule_id: int) -> Dict[str, Any]: """ Generate and send a scheduled report. - + This is called by the scheduled task. - + Returns: dict with 'success', 'message', and 'sent_count' keys """ try: schedule = ReportEmailSchedule.query.get(schedule_id) if not schedule or not schedule.active: - return { - 'success': False, - 'message': 'Schedule not found or inactive.' - } - + return {"success": False, "message": "Schedule not found or inactive."} + saved_view = SavedReportView.query.get(schedule.saved_view_id) if not saved_view: - return { - 'success': False, - 'message': 'Saved report view not found.' - } - + return {"success": False, "message": "Saved report view not found."} + # Parse report configuration try: - config = json.loads(saved_view.config_json) if isinstance(saved_view.config_json, str) else saved_view.config_json + config = ( + json.loads(saved_view.config_json) + if isinstance(saved_view.config_json, str) + else saved_view.config_json + ) except: config = {} - + # Generate report data based on config report_data = self._generate_report_data(saved_view, config) - + # Send email to recipients - recipients = [email.strip() for email in schedule.recipients.split(',')] + recipients = [email.strip() for email in schedule.recipients.split(",")] sent_count = 0 - + for recipient in recipients: try: send_email( to=recipient, - subject=f'Scheduled Report: {saved_view.name}', - template='email/scheduled_report.html', + subject=f"Scheduled Report: {saved_view.name}", + template="email/scheduled_report.html", report_name=saved_view.name, report_data=report_data, - generated_at=now_in_app_timezone() + generated_at=now_in_app_timezone(), ) sent_count += 1 except Exception as e: logger.error(f"Error sending report email to {recipient}: {e}") - + # Update schedule schedule.last_run_at = now_in_app_timezone() - schedule.next_run_at = self._calculate_next_run( - schedule.cadence, - schedule.cron, - schedule.timezone - ) - + schedule.next_run_at = self._calculate_next_run(schedule.cadence, schedule.cron, schedule.timezone) + db.session.commit() - - return { - 'success': True, - 'message': f'Report sent to {sent_count} recipients.', - 'sent_count': sent_count - } + + return {"success": True, "message": f"Report sent to {sent_count} recipients.", "sent_count": sent_count} except Exception as e: db.session.rollback() logger.error(f"Error generating and sending report: {e}") - return { - 'success': False, - 'message': f'Error generating report: {str(e)}' - } - - def _generate_report_data( - self, - saved_view: SavedReportView, - config: Dict[str, Any] - ) -> Dict[str, Any]: + return {"success": False, "message": f"Error generating report: {str(e)}"} + + def _generate_report_data(self, saved_view: SavedReportView, config: Dict[str, Any]) -> Dict[str, Any]: """ Generate report data based on saved view configuration. - + Returns: dict with report data """ # Extract filters from config - start_date = config.get('start_date') - end_date = config.get('end_date') - project_id = config.get('project_id') - user_id = config.get('user_id') - + start_date = config.get("start_date") + end_date = config.get("end_date") + project_id = config.get("project_id") + user_id = config.get("user_id") + # Convert date strings to datetime if needed if isinstance(start_date, str): start_date = datetime.fromisoformat(start_date) if isinstance(end_date, str): end_date = datetime.fromisoformat(end_date) - + # Generate appropriate report based on scope scope = saved_view.scope - - if scope == 'time': + + if scope == "time": return self.reporting_service.get_time_summary( - user_id=user_id, - project_id=project_id, - start_date=start_date, - end_date=end_date + user_id=user_id, project_id=project_id, start_date=start_date, end_date=end_date ) - elif scope == 'project': + elif scope == "project": return self.reporting_service.get_project_summary( - project_id=project_id, - start_date=start_date, - end_date=end_date + project_id=project_id, start_date=start_date, end_date=end_date ) - elif scope == 'invoice': + elif scope == "invoice": # Would need invoice service - return {'message': 'Invoice reports not yet implemented'} + return {"message": "Invoice reports not yet implemented"} else: - return {'message': 'Unknown report scope'} - - def _calculate_next_run( - self, - cadence: str, - cron: Optional[str], - timezone: Optional[str] - ) -> datetime: + return {"message": "Unknown report scope"} + + def _calculate_next_run(self, cadence: str, cron: Optional[str], timezone: Optional[str]) -> datetime: """ Calculate next run time for a schedule. - + Returns: datetime for next run """ now = now_in_app_timezone() - - if cadence == 'daily': + + if cadence == "daily": # Next day at 8 AM next_run = now + timedelta(days=1) return next_run.replace(hour=8, minute=0, second=0, microsecond=0) - elif cadence == 'weekly': + elif cadence == "weekly": # Next Monday at 8 AM days_until_monday = (7 - now.weekday()) % 7 if days_until_monday == 0: days_until_monday = 7 next_run = now + timedelta(days=days_until_monday) return next_run.replace(hour=8, minute=0, second=0, microsecond=0) - elif cadence == 'monthly': + elif cadence == "monthly": # First day of next month at 8 AM if now.month == 12: next_run = now.replace(year=now.year + 1, month=1, day=1, hour=8, minute=0, second=0, microsecond=0) else: next_run = now.replace(month=now.month + 1, day=1, hour=8, minute=0, second=0, microsecond=0) return next_run - elif cadence == 'custom-cron' and cron: + elif cadence == "custom-cron" and cron: # For custom cron, we'd need a cron parser # For now, return next day next_run = now + timedelta(days=1) @@ -251,16 +211,12 @@ def _calculate_next_run( # Default: next day next_run = now + timedelta(days=1) return next_run.replace(hour=8, minute=0, second=0, microsecond=0) - + def get_schedule(self, schedule_id: int) -> Optional[ReportEmailSchedule]: """Get a schedule by ID""" return ReportEmailSchedule.query.get(schedule_id) - - def list_schedules( - self, - user_id: Optional[int] = None, - active_only: bool = True - ) -> List[ReportEmailSchedule]: + + def list_schedules(self, user_id: Optional[int] = None, active_only: bool = True) -> List[ReportEmailSchedule]: """List scheduled reports""" query = ReportEmailSchedule.query if user_id: @@ -268,93 +224,54 @@ def list_schedules( if active_only: query = query.filter_by(active=True) return query.order_by(ReportEmailSchedule.next_run_at.asc()).all() - - def update_schedule( - self, - schedule_id: int, - user_id: int, - **kwargs - ) -> Dict[str, Any]: + + def update_schedule(self, schedule_id: int, user_id: int, **kwargs) -> Dict[str, Any]: """Update a scheduled report""" try: schedule = ReportEmailSchedule.query.get(schedule_id) if not schedule: - return { - 'success': False, - 'message': 'Schedule not found.' - } - + return {"success": False, "message": "Schedule not found."} + if schedule.created_by != user_id: - return { - 'success': False, - 'message': 'You do not have permission to edit this schedule.' - } - - if 'recipients' in kwargs: - schedule.recipients = kwargs['recipients'] - if 'cadence' in kwargs: - schedule.cadence = kwargs['cadence'] - if 'cron' in kwargs: - schedule.cron = kwargs['cron'] - if 'timezone' in kwargs: - schedule.timezone = kwargs['timezone'] - if 'active' in kwargs: - schedule.active = kwargs['active'] - - if 'cadence' in kwargs or 'cron' in kwargs or 'timezone' in kwargs: - schedule.next_run_at = self._calculate_next_run( - schedule.cadence, - schedule.cron, - schedule.timezone - ) - + return {"success": False, "message": "You do not have permission to edit this schedule."} + + if "recipients" in kwargs: + schedule.recipients = kwargs["recipients"] + if "cadence" in kwargs: + schedule.cadence = kwargs["cadence"] + if "cron" in kwargs: + schedule.cron = kwargs["cron"] + if "timezone" in kwargs: + schedule.timezone = kwargs["timezone"] + if "active" in kwargs: + schedule.active = kwargs["active"] + + if "cadence" in kwargs or "cron" in kwargs or "timezone" in kwargs: + schedule.next_run_at = self._calculate_next_run(schedule.cadence, schedule.cron, schedule.timezone) + db.session.commit() - - return { - 'success': True, - 'message': 'Schedule updated successfully.', - 'schedule': schedule - } + + return {"success": True, "message": "Schedule updated successfully.", "schedule": schedule} except Exception as e: db.session.rollback() logger.error(f"Error updating schedule: {e}") - return { - 'success': False, - 'message': f'Error updating schedule: {str(e)}' - } - - def delete_schedule( - self, - schedule_id: int, - user_id: int - ) -> Dict[str, Any]: + return {"success": False, "message": f"Error updating schedule: {str(e)}"} + + def delete_schedule(self, schedule_id: int, user_id: int) -> Dict[str, Any]: """Delete a scheduled report""" try: schedule = ReportEmailSchedule.query.get(schedule_id) if not schedule: - return { - 'success': False, - 'message': 'Schedule not found.' - } - + return {"success": False, "message": "Schedule not found."} + if schedule.created_by != user_id: - return { - 'success': False, - 'message': 'You do not have permission to delete this schedule.' - } - + return {"success": False, "message": "You do not have permission to delete this schedule."} + db.session.delete(schedule) db.session.commit() - - return { - 'success': True, - 'message': 'Schedule deleted successfully.' - } + + return {"success": True, "message": "Schedule deleted successfully."} except Exception as e: db.session.rollback() logger.error(f"Error deleting schedule: {e}") - return { - 'success': False, - 'message': f'Error deleting schedule: {str(e)}' - } - + return {"success": False, "message": f"Error deleting schedule: {str(e)}"} diff --git a/app/services/task_service.py b/app/services/task_service.py index 389958a1..ef150742 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -15,16 +15,16 @@ class TaskService: """ Service for task business logic operations. - + This service handles all task-related business logic including: - Creating and updating tasks - Listing tasks with filtering and pagination - Getting task details with related data - Task assignment and status management - + All methods use the repository pattern for data access and include eager loading to prevent N+1 query problems. - + Example: service = TaskService() result = service.create_task( @@ -35,14 +35,14 @@ class TaskService: if result['success']: task = result['task'] """ - + def __init__(self): """ Initialize TaskService with required repositories. """ self.task_repo = TaskRepository() self.project_repo = ProjectRepository() - + def create_task( self, name: str, @@ -50,13 +50,13 @@ def create_task( created_by: int, description: Optional[str] = None, assignee_id: Optional[int] = None, - priority: str = 'medium', + priority: str = "medium", due_date: Optional[Any] = None, - estimated_hours: Optional[float] = None + estimated_hours: Optional[float] = None, ) -> Dict[str, Any]: """ Create a new task. - + Args: name: Task name project_id: Project ID @@ -66,19 +66,15 @@ def create_task( due_date: Due date estimated_hours: Estimated hours created_by: User ID of creator - + Returns: dict with 'success', 'message', and 'task' keys """ # Validate project project = self.project_repo.get_by_id(project_id) if not project: - return { - 'success': False, - 'message': 'Invalid project', - 'error': 'invalid_project' - } - + return {"success": False, "message": "Invalid project", "error": "invalid_project"} + # Create task task = self.task_repo.create( name=name, @@ -89,121 +85,90 @@ def create_task( due_date=due_date, estimated_hours=estimated_hours, status=TaskStatus.TODO.value, - created_by=created_by + created_by=created_by, ) - - if not safe_commit('create_task', {'project_id': project_id, 'created_by': created_by}): + + if not safe_commit("create_task", {"project_id": project_id, "created_by": created_by}): return { - 'success': False, - 'message': 'Could not create task due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create task due to a database error", + "error": "database_error", } - + # Emit domain event - emit_event(WebhookEvent.TASK_CREATED.value, { - 'task_id': task.id, - 'project_id': project_id, - 'created_by': created_by - }) - - return { - 'success': True, - 'message': 'Task created successfully', - 'task': task - } - + emit_event( + WebhookEvent.TASK_CREATED.value, {"task_id": task.id, "project_id": project_id, "created_by": created_by} + ) + + return {"success": True, "message": "Task created successfully", "task": task} + def get_task_with_details( self, task_id: int, include_time_entries: bool = True, include_comments: bool = True, - include_activities: bool = True + include_activities: bool = True, ) -> Optional[Task]: """ Get task with all related data using eager loading to prevent N+1 queries. - + Args: task_id: The task ID include_time_entries: Whether to include time entries include_comments: Whether to include comments include_activities: Whether to include activities - + Returns: Task with eagerly loaded relations, or None if not found """ from sqlalchemy.orm import joinedload from app.models import TimeEntry, Comment, TaskActivity - + query = self.task_repo.query().filter_by(id=task_id) - + # Eagerly load project and assignee - query = query.options( - joinedload(Task.project), - joinedload(Task.assigned_user), - joinedload(Task.creator) - ) - + query = query.options(joinedload(Task.project), joinedload(Task.assigned_user), joinedload(Task.creator)) + # Conditionally load relations # Note: time_entries is a dynamic relationship (lazy='dynamic') and cannot be eager loaded # Time entries must be queried separately using task.time_entries.order_by(...).all() - + if include_comments: query = query.options(joinedload(Task.comments).joinedload(Comment.author)) - + # Note: activities is a dynamic relationship (lazy='dynamic') and cannot be eager loaded # Activities must be queried separately using task.activities.order_by(...).all() - + return query.first() - - def update_task( - self, - task_id: int, - user_id: int, - **kwargs - ) -> Dict[str, Any]: + + def update_task(self, task_id: int, user_id: int, **kwargs) -> Dict[str, Any]: """ Update a task. - + Returns: dict with 'success', 'message', and 'task' keys """ task = self.task_repo.get_by_id(task_id) - + if not task: - return { - 'success': False, - 'message': 'Task not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Task not found", "error": "not_found"} + # Update fields self.task_repo.update(task, **kwargs) - - if not safe_commit('update_task', {'task_id': task_id, 'user_id': user_id}): + + if not safe_commit("update_task", {"task_id": task_id, "user_id": user_id}): return { - 'success': False, - 'message': 'Could not update task due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not update task due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Task updated successfully', - 'task': task - } - - def get_project_tasks( - self, - project_id: int, - status: Optional[str] = None - ) -> List[Task]: + + return {"success": True, "message": "Task updated successfully", "task": task} + + def get_project_tasks(self, project_id: int, status: Optional[str] = None) -> List[Task]: """Get tasks for a project""" - return self.task_repo.get_by_project( - project_id=project_id, - status=status, - include_relations=True - ) - + return self.task_repo.get_by_project(project_id=project_id, status=status, include_relations=True) + def list_tasks( self, status: Optional[str] = None, @@ -215,84 +180,58 @@ def list_tasks( user_id: Optional[int] = None, is_admin: bool = False, page: int = 1, - per_page: int = 20 + per_page: int = 20, ) -> Dict[str, Any]: """ List tasks with filtering and pagination. Uses eager loading to prevent N+1 queries. - + Returns: dict with 'tasks', 'pagination', and 'total' keys """ from sqlalchemy.orm import joinedload from app.utils.timezone import now_in_app_timezone - + query = self.task_repo.query() - + # Eagerly load relations to prevent N+1 - query = query.options( - joinedload(Task.project), - joinedload(Task.assigned_user), - joinedload(Task.creator) - ) - + query = query.options(joinedload(Task.project), joinedload(Task.assigned_user), joinedload(Task.creator)) + # Apply filters if status: query = query.filter(Task.status == status) - + if priority: query = query.filter(Task.priority == priority) - + if project_id: query = query.filter(Task.project_id == project_id) - + if assigned_to: query = query.filter(Task.assigned_to == assigned_to) - + if search: like = f"%{search}%" - query = query.filter( - db.or_( - Task.name.ilike(like), - Task.description.ilike(like) - ) - ) - + query = query.filter(db.or_(Task.name.ilike(like), Task.description.ilike(like))) + # Overdue filter if overdue: today_local = now_in_app_timezone().date() - query = query.filter( - Task.due_date < today_local, - Task.status.in_(['todo', 'in_progress', 'review']) - ) - + query = query.filter(Task.due_date < today_local, Task.status.in_(["todo", "in_progress", "review"])) + # Permission filter - non-admins only see their tasks if not is_admin and user_id: - query = query.filter( - db.or_( - Task.assigned_to == user_id, - Task.created_by == user_id - ) - ) - + query = query.filter(db.or_(Task.assigned_to == user_id, Task.created_by == user_id)) + # Order by priority, due date, created date - query = query.order_by( - Task.priority.desc(), - Task.due_date.asc(), - Task.created_at.asc() - ) - + query = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()) + # Determine pagination has_filters = bool(status or priority or project_id or assigned_to or search or overdue) if not has_filters: per_page = 10000 # Show all if no filters - + # Paginate pagination = query.paginate(page=page, per_page=per_page, error_out=False) - - return { - 'tasks': pagination.items, - 'pagination': pagination, - 'total': pagination.total - } + return {"tasks": pagination.items, "pagination": pagination, "total": pagination.total} diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index cdf48693..c803701c 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -17,32 +17,30 @@ class TimeTrackingService: """Service for time tracking operations""" - + def __init__(self): self.time_entry_repo = TimeEntryRepository() self.project_repo = ProjectRepository() - + def start_timer( self, user_id: int, project_id: int, task_id: Optional[int] = None, notes: Optional[str] = None, - template_id: Optional[int] = None + template_id: Optional[int] = None, ) -> Dict[str, Any]: """ Start a new timer for a user. - + Returns: dict with 'success', 'message', and 'timer' keys """ # Load template if provided if template_id: from app.models import TimeEntryTemplate - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=user_id - ).first() + + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=user_id).first() if template: # Override with template values if not explicitly set if not project_id and template.project_id: @@ -64,42 +62,36 @@ def start_timer( active_timer = self.time_entry_repo.get_active_timer(user_id) if active_timer: return { - 'success': False, - 'message': 'You already have an active timer. Stop it before starting a new one.', - 'error': 'timer_already_running' + "success": False, + "message": "You already have an active timer. Stop it before starting a new one.", + "error": "timer_already_running", } - + # Validate project project = self.project_repo.get_by_id(project_id) if not project: - return { - 'success': False, - 'message': 'Invalid project selected', - 'error': 'invalid_project' - } - + return {"success": False, "message": "Invalid project selected", "error": "invalid_project"} + # Check project status - if project.status == 'archived': + if project.status == "archived": return { - 'success': False, - 'message': 'Cannot start timer for an archived project. Please unarchive the project first.', - 'error': 'project_archived' + "success": False, + "message": "Cannot start timer for an archived project. Please unarchive the project first.", + "error": "project_archived", } - - if project.status != 'active': + + if project.status != "active": return { - 'success': False, - 'message': 'Cannot start timer for an inactive project', - 'error': 'project_inactive' + "success": False, + "message": "Cannot start timer for an inactive project", + "error": "project_inactive", } - + # Load template if provided if template_id: from app.models import TimeEntryTemplate - template = TimeEntryTemplate.query.filter_by( - id=template_id, - user_id=user_id - ).first() + + template = TimeEntryTemplate.query.filter_by(id=template_id, user_id=user_id).first() if template: if not project_id and template.project_id: project_id = template.project_id @@ -108,50 +100,40 @@ def start_timer( if not notes and template.default_notes: notes = template.default_notes template.record_usage() - + # Validate task if provided if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: return { - 'success': False, - 'message': 'Selected task is invalid for the chosen project', - 'error': 'invalid_task' + "success": False, + "message": "Selected task is invalid for the chosen project", + "error": "invalid_task", } - + # Create timer timer = self.time_entry_repo.create_timer( - user_id=user_id, - project_id=project_id, - task_id=task_id, - notes=notes, - source=TimeEntrySource.AUTO.value + user_id=user_id, project_id=project_id, task_id=task_id, notes=notes, source=TimeEntrySource.AUTO.value ) - - if not safe_commit('start_timer', {'user_id': user_id, 'project_id': project_id}): + + if not safe_commit("start_timer", {"user_id": user_id, "project_id": project_id}): return { - 'success': False, - 'message': 'Could not start timer due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not start timer due to a database error", + "error": "database_error", } - + # Emit domain event - emit_event(WebhookEvent.TIME_ENTRY_CREATED.value, { - 'entry_id': timer.id, - 'user_id': user_id, - 'project_id': project_id - }) - - return { - 'success': True, - 'message': 'Timer started successfully', - 'timer': timer - } - + emit_event( + WebhookEvent.TIME_ENTRY_CREATED.value, {"entry_id": timer.id, "user_id": user_id, "project_id": project_id} + ) + + return {"success": True, "message": "Timer started successfully", "timer": timer} + def stop_timer(self, user_id: int, entry_id: Optional[int] = None) -> Dict[str, Any]: """ Stop the active timer for a user. - + Returns: dict with 'success', 'message', and 'entry' keys """ @@ -159,45 +141,29 @@ def stop_timer(self, user_id: int, entry_id: Optional[int] = None) -> Dict[str, entry = self.time_entry_repo.get_by_id(entry_id) else: entry = self.time_entry_repo.get_active_timer(user_id) - + if not entry: - return { - 'success': False, - 'message': 'No active timer found', - 'error': 'no_active_timer' - } - + return {"success": False, "message": "No active timer found", "error": "no_active_timer"} + if entry.user_id != user_id: - return { - 'success': False, - 'message': 'You can only stop your own timer', - 'error': 'unauthorized' - } - + return {"success": False, "message": "You can only stop your own timer", "error": "unauthorized"} + if entry.end_time is not None: - return { - 'success': False, - 'message': 'Timer is already stopped', - 'error': 'timer_already_stopped' - } - + return {"success": False, "message": "Timer is already stopped", "error": "timer_already_stopped"} + # Stop the timer entry.end_time = local_now() entry.calculate_duration() - - if not safe_commit('stop_timer', {'user_id': user_id, 'entry_id': entry.id}): + + if not safe_commit("stop_timer", {"user_id": user_id, "entry_id": entry.id}): return { - 'success': False, - 'message': 'Could not stop timer due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not stop timer due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Timer stopped successfully', - 'entry': entry - } - + + return {"success": True, "message": "Timer stopped successfully", "entry": entry} + def create_manual_entry( self, user_id: int, @@ -207,41 +173,29 @@ def create_manual_entry( task_id: Optional[int] = None, notes: Optional[str] = None, tags: Optional[str] = None, - billable: bool = True + billable: bool = True, ) -> Dict[str, Any]: """ Create a manual time entry. - + Returns: dict with 'success', 'message', and 'entry' keys """ # Validate project project = self.project_repo.get_by_id(project_id) if not project: - return { - 'success': False, - 'message': 'Invalid project', - 'error': 'invalid_project' - } - + return {"success": False, "message": "Invalid project", "error": "invalid_project"} + # Validate time range if end_time <= start_time: - return { - 'success': False, - 'message': 'End time must be after start time', - 'error': 'invalid_time_range' - } - + return {"success": False, "message": "End time must be after start time", "error": "invalid_time_range"} + # Validate task if provided if task_id: task = Task.query.filter_by(id=task_id, project_id=project_id).first() if not task: - return { - 'success': False, - 'message': 'Invalid task for selected project', - 'error': 'invalid_task' - } - + return {"success": False, "message": "Invalid task for selected project", "error": "invalid_task"} + # Create entry entry = self.time_entry_repo.create_manual_entry( user_id=user_id, @@ -251,22 +205,18 @@ def create_manual_entry( task_id=task_id, notes=notes, tags=tags, - billable=billable + billable=billable, ) - - if not safe_commit('create_manual_entry', {'user_id': user_id, 'project_id': project_id}): + + if not safe_commit("create_manual_entry", {"user_id": user_id, "project_id": project_id}): return { - 'success': False, - 'message': 'Could not create time entry due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create time entry due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'Time entry created successfully', - 'entry': entry - } - + + return {"success": True, "message": "Time entry created successfully", "entry": entry} + def get_user_entries( self, user_id: int, @@ -274,71 +224,48 @@ def get_user_entries( offset: int = 0, project_id: Optional[int] = None, start_date: Optional[datetime] = None, - end_date: Optional[datetime] = None + end_date: Optional[datetime] = None, ) -> List[TimeEntry]: """Get time entries for a user with optional filters""" if start_date and end_date: return self.time_entry_repo.get_by_date_range( - start_date=start_date, - end_date=end_date, - user_id=user_id, - project_id=project_id, - include_relations=True + start_date=start_date, end_date=end_date, user_id=user_id, project_id=project_id, include_relations=True ) elif project_id: return self.time_entry_repo.get_by_project( - project_id=project_id, - limit=limit, - offset=offset, - include_relations=True + project_id=project_id, limit=limit, offset=offset, include_relations=True ) else: - return self.time_entry_repo.get_by_user( - user_id=user_id, - limit=limit, - offset=offset, - include_relations=True - ) - + return self.time_entry_repo.get_by_user(user_id=user_id, limit=limit, offset=offset, include_relations=True) + def get_active_timer(self, user_id: int) -> Optional[TimeEntry]: """Get the active timer for a user""" return self.time_entry_repo.get_active_timer(user_id) - + def delete_entry(self, user_id: int, entry_id: int) -> Dict[str, Any]: """ Delete a time entry. - + Returns: dict with 'success' and 'message' keys """ entry = self.time_entry_repo.get_by_id(entry_id) - + if not entry: - return { - 'success': False, - 'message': 'Time entry not found', - 'error': 'not_found' - } - + return {"success": False, "message": "Time entry not found", "error": "not_found"} + # Check permissions (user can only delete their own entries unless admin) from flask_login import current_user - if entry.user_id != user_id and not (hasattr(current_user, 'is_admin') and current_user.is_admin): + + if entry.user_id != user_id and not (hasattr(current_user, "is_admin") and current_user.is_admin): return { - 'success': False, - 'message': 'You do not have permission to delete this entry', - 'error': 'unauthorized' + "success": False, + "message": "You do not have permission to delete this entry", + "error": "unauthorized", } - + if self.time_entry_repo.delete(entry): - if safe_commit('delete_entry', {'user_id': user_id, 'entry_id': entry_id}): - return { - 'success': True, - 'message': 'Time entry deleted successfully' - } - - return { - 'success': False, - 'message': 'Could not delete time entry', - 'error': 'database_error' - } + if safe_commit("delete_entry", {"user_id": user_id, "entry_id": entry_id}): + return {"success": True, "message": "Time entry deleted successfully"} + return {"success": False, "message": "Could not delete time entry", "error": "database_error"} diff --git a/app/services/user_service.py b/app/services/user_service.py index a1a5cc6f..6893987c 100644 --- a/app/services/user_service.py +++ b/app/services/user_service.py @@ -12,10 +12,10 @@ class UserService: """Service for user operations""" - + def __init__(self): self.user_repo = UserRepository() - + def create_user( self, username: str, @@ -23,140 +23,103 @@ def create_user( role: str = UserRole.USER.value, email: Optional[str] = None, full_name: Optional[str] = None, - is_active: bool = True + is_active: bool = True, ) -> Dict[str, Any]: """ Create a new user. - + Returns: dict with 'success', 'message', and 'user' keys """ # Check for duplicate username existing = self.user_repo.get_by_username(username) if existing: - return { - 'success': False, - 'message': 'Username already exists', - 'error': 'duplicate_username' - } - + return {"success": False, "message": "Username already exists", "error": "duplicate_username"} + # Validate role valid_roles = [r.value for r in UserRole] if role not in valid_roles: return { - 'success': False, - 'message': f'Invalid role. Must be one of: {", ".join(valid_roles)}', - 'error': 'invalid_role' + "success": False, + "message": f'Invalid role. Must be one of: {", ".join(valid_roles)}', + "error": "invalid_role", } - + # Create user user = self.user_repo.create( - username=username, - role=role, - email=email, - full_name=full_name, - is_active=is_active + username=username, role=role, email=email, full_name=full_name, is_active=is_active ) - - if not safe_commit('create_user', {'username': username, 'created_by': created_by}): + + if not safe_commit("create_user", {"username": username, "created_by": created_by}): return { - 'success': False, - 'message': 'Could not create user due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not create user due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'User created successfully', - 'user': user - } - - def update_user( - self, - user_id: int, - updated_by: int, - **kwargs - ) -> Dict[str, Any]: + + return {"success": True, "message": "User created successfully", "user": user} + + def update_user(self, user_id: int, updated_by: int, **kwargs) -> Dict[str, Any]: """ Update a user. - + Returns: dict with 'success', 'message', and 'user' keys """ user = self.user_repo.get_by_id(user_id) - + if not user: - return { - 'success': False, - 'message': 'User not found', - 'error': 'not_found' - } - + return {"success": False, "message": "User not found", "error": "not_found"} + # Validate role if being updated - if 'role' in kwargs: + if "role" in kwargs: valid_roles = [r.value for r in UserRole] - if kwargs['role'] not in valid_roles: + if kwargs["role"] not in valid_roles: return { - 'success': False, - 'message': f'Invalid role. Must be one of: {", ".join(valid_roles)}', - 'error': 'invalid_role' + "success": False, + "message": f'Invalid role. Must be one of: {", ".join(valid_roles)}', + "error": "invalid_role", } - + # Update fields self.user_repo.update(user, **kwargs) - - if not safe_commit('update_user', {'user_id': user_id, 'updated_by': updated_by}): + + if not safe_commit("update_user", {"user_id": user_id, "updated_by": updated_by}): return { - 'success': False, - 'message': 'Could not update user due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not update user due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'User updated successfully', - 'user': user - } - - def deactivate_user( - self, - user_id: int, - deactivated_by: int - ) -> Dict[str, Any]: + + return {"success": True, "message": "User updated successfully", "user": user} + + def deactivate_user(self, user_id: int, deactivated_by: int) -> Dict[str, Any]: """ Deactivate a user. - + Returns: dict with 'success' and 'message' keys """ user = self.user_repo.get_by_id(user_id) - + if not user: - return { - 'success': False, - 'message': 'User not found', - 'error': 'not_found' - } - + return {"success": False, "message": "User not found", "error": "not_found"} + user.is_active = False - - if not safe_commit('deactivate_user', {'user_id': user_id, 'deactivated_by': deactivated_by}): + + if not safe_commit("deactivate_user", {"user_id": user_id, "deactivated_by": deactivated_by}): return { - 'success': False, - 'message': 'Could not deactivate user due to a database error', - 'error': 'database_error' + "success": False, + "message": "Could not deactivate user due to a database error", + "error": "database_error", } - - return { - 'success': True, - 'message': 'User deactivated successfully' - } - + + return {"success": True, "message": "User deactivated successfully"} + def get_active_users(self) -> List[User]: """Get all active users""" return self.user_repo.get_active_users() - + def get_by_role(self, role: str) -> List[User]: """Get users by role""" return self.user_repo.get_by_role(role) - diff --git a/app/utils/api_auth.py b/app/utils/api_auth.py index 6d56cd26..1a7fbca2 100644 --- a/app/utils/api_auth.py +++ b/app/utils/api_auth.py @@ -1,4 +1,5 @@ """API Token Authentication utilities for REST API""" + from functools import wraps from flask import request, jsonify, g, current_app from app.models import ApiToken, User @@ -7,77 +8,77 @@ def extract_token_from_request(): """Extract API token from request headers - + Supports multiple formats: - Authorization: Bearer - Authorization: Token - X-API-Key: - + Returns: str or None: The token if found """ # Check Authorization header - auth_header = request.headers.get('Authorization', '') + auth_header = request.headers.get("Authorization", "") if auth_header: parts = auth_header.split() if len(parts) == 2: scheme = parts[0].lower() - if scheme in ('bearer', 'token'): + if scheme in ("bearer", "token"): return parts[1] - + # Check X-API-Key header - api_key = request.headers.get('X-API-Key') + api_key = request.headers.get("X-API-Key") if api_key: return api_key - + return None def authenticate_token(token_string): """Authenticate an API token and return the associated user - + Args: token_string: The plain token string - + Returns: tuple: (User, ApiToken) if valid, (None, None) otherwise """ - if not token_string or not token_string.startswith('tt_'): + if not token_string or not token_string.startswith("tt_"): return None, None - + # Get token hash token_hash = ApiToken.hash_token(token_string) - + # Find token in database api_token = ApiToken.query.filter_by(token_hash=token_hash).first() - + if not api_token: return None, None - + # Check if token is valid if not api_token.is_valid(): return None, None - + # Get associated user user = User.query.get(api_token.user_id) if not user or not user.is_active: return None, None - + # Record usage try: api_token.record_usage(request.remote_addr) except Exception as e: current_app.logger.warning(f"Failed to record API token usage: {e}") - + return user, api_token def require_api_token(required_scope=None): """Decorator to require API token authentication - + Args: required_scope: Optional scope required for this endpoint (e.g., 'read:projects') - + Usage: @require_api_token('read:projects') def get_projects(): @@ -85,52 +86,63 @@ def get_projects(): # Access token via g.api_token pass """ + def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): # Extract token from request token_string = extract_token_from_request() - + if not token_string: - return jsonify({ - 'error': 'Authentication required', - 'message': 'API token must be provided in Authorization header or X-API-Key header' - }), 401 - + return ( + jsonify( + { + "error": "Authentication required", + "message": "API token must be provided in Authorization header or X-API-Key header", + } + ), + 401, + ) + # Authenticate token user, api_token = authenticate_token(token_string) - + if not user or not api_token: - return jsonify({ - 'error': 'Invalid token', - 'message': 'The provided API token is invalid or expired' - }), 401 - + return ( + jsonify({"error": "Invalid token", "message": "The provided API token is invalid or expired"}), + 401, + ) + # Check scope if required if required_scope and not api_token.has_scope(required_scope): - return jsonify({ - 'error': 'Insufficient permissions', - 'message': f'This endpoint requires the "{required_scope}" scope', - 'required_scope': required_scope, - 'available_scopes': api_token.scopes.split(',') if api_token.scopes else [] - }), 403 - + return ( + jsonify( + { + "error": "Insufficient permissions", + "message": f'This endpoint requires the "{required_scope}" scope', + "required_scope": required_scope, + "available_scopes": api_token.scopes.split(",") if api_token.scopes else [], + } + ), + 403, + ) + # Store in request context g.api_user = user g.api_token = api_token - + return f(*args, **kwargs) - + return decorated_function - + return decorator def optional_api_token(): """Decorator that allows both session-based and token-based authentication - + Useful for endpoints that can be accessed via web UI or API - + Usage: @optional_api_token() @login_required # Will be satisfied by API token if present @@ -138,21 +150,21 @@ def get_data(): # Access user via current_user (session) or g.api_user (token) pass """ + def decorator(f): @wraps(f) def decorated_function(*args, **kwargs): # Try to extract and authenticate token token_string = extract_token_from_request() - + if token_string: user, api_token = authenticate_token(token_string) if user and api_token: g.api_user = user g.api_token = api_token - + return f(*args, **kwargs) - + return decorated_function - - return decorator + return decorator diff --git a/app/utils/api_responses.py b/app/utils/api_responses.py index a69b4faa..855f3e0d 100644 --- a/app/utils/api_responses.py +++ b/app/utils/api_responses.py @@ -9,36 +9,33 @@ def success_response( - data: Any = None, - message: Optional[str] = None, - status_code: int = 200, - meta: Optional[Dict[str, Any]] = None + data: Any = None, message: Optional[str] = None, status_code: int = 200, meta: Optional[Dict[str, Any]] = None ) -> Response: """ Create a successful API response. - + Args: data: Response data message: Optional success message status_code: HTTP status code meta: Optional metadata - + Returns: Flask JSON response """ response = { - 'success': True, + "success": True, } - + if message: - response['message'] = message - + response["message"] = message + if data is not None: - response['data'] = data - + response["data"] = data + if meta: - response['meta'] = meta - + response["meta"] = meta + return jsonify(response), status_code @@ -47,164 +44,129 @@ def error_response( error_code: Optional[str] = None, status_code: int = 400, errors: Optional[Dict[str, List[str]]] = None, - details: Optional[Dict[str, Any]] = None + details: Optional[Dict[str, Any]] = None, ) -> Response: """ Create an error API response. - + Args: message: Error message error_code: Optional error code status_code: HTTP status code errors: Optional field-specific errors details: Optional additional error details - + Returns: Flask JSON response """ - response = { - 'success': False, - 'error': error_code or 'error', - 'message': message - } - + response = {"success": False, "error": error_code or "error", "message": message} + if errors: - response['errors'] = errors - + response["errors"] = errors + if details: - response['details'] = details - + response["details"] = details + return jsonify(response), status_code -def validation_error_response( - errors: Dict[str, List[str]], - message: str = "Validation failed" -) -> Response: +def validation_error_response(errors: Dict[str, List[str]], message: str = "Validation failed") -> Response: """ Create a validation error response. - + Args: errors: Field-specific validation errors message: Error message - + Returns: Flask JSON response """ - return error_response( - message=message, - error_code='validation_error', - status_code=400, - errors=errors - ) + return error_response(message=message, error_code="validation_error", status_code=400, errors=errors) -def not_found_response( - resource: str = "Resource", - resource_id: Optional[Any] = None -) -> Response: +def not_found_response(resource: str = "Resource", resource_id: Optional[Any] = None) -> Response: """ Create a not found error response. - + Args: resource: Resource type name resource_id: Optional resource ID - + Returns: Flask JSON response """ message = f"{resource} not found" if resource_id is not None: message = f"{resource} with ID {resource_id} not found" - - return error_response( - message=message, - error_code='not_found', - status_code=404 - ) + + return error_response(message=message, error_code="not_found", status_code=404) def unauthorized_response(message: str = "Authentication required") -> Response: """ Create an unauthorized error response. - + Args: message: Error message - + Returns: Flask JSON response """ - return error_response( - message=message, - error_code='unauthorized', - status_code=401 - ) + return error_response(message=message, error_code="unauthorized", status_code=401) def forbidden_response(message: str = "Insufficient permissions") -> Response: """ Create a forbidden error response. - + Args: message: Error message - + Returns: Flask JSON response """ - return error_response( - message=message, - error_code='forbidden', - status_code=403 - ) + return error_response(message=message, error_code="forbidden", status_code=403) def paginated_response( - items: List[Any], - page: int, - per_page: int, - total: int, - message: Optional[str] = None + items: List[Any], page: int, per_page: int, total: int, message: Optional[str] = None ) -> Response: """ Create a paginated response. - + Args: items: List of items for current page page: Current page number per_page: Items per page total: Total number of items message: Optional message - + Returns: Flask JSON response """ pages = (total + per_page - 1) // per_page if total > 0 else 0 - + pagination = { - 'page': page, - 'per_page': per_page, - 'total': total, - 'pages': pages, - 'has_next': page < pages, - 'has_prev': page > 1, - 'next_page': page + 1 if page < pages else None, - 'prev_page': page - 1 if page > 1 else None + "page": page, + "per_page": per_page, + "total": total, + "pages": pages, + "has_next": page < pages, + "has_prev": page > 1, + "next_page": page + 1 if page < pages else None, + "prev_page": page - 1 if page > 1 else None, } - - return success_response( - data=items, - message=message, - meta={'pagination': pagination} - ) + + return success_response(data=items, message=message, meta={"pagination": pagination}) def handle_validation_error(error: ValidationError) -> Response: """ Handle Marshmallow validation errors. - + Args: error: ValidationError instance - + Returns: Flask JSON response """ @@ -212,46 +174,41 @@ def handle_validation_error(error: ValidationError) -> Response: if isinstance(error.messages, dict): errors = error.messages elif isinstance(error.messages, list): - errors = {'_general': error.messages} - + errors = {"_general": error.messages} + return validation_error_response(errors=errors) -def created_response( - data: Any, - message: Optional[str] = None, - location: Optional[str] = None -) -> Response: +def created_response(data: Any, message: Optional[str] = None, location: Optional[str] = None) -> Response: """ Create a 201 Created response. - + Args: data: Created resource data message: Optional success message location: Optional resource location URL - + Returns: Flask JSON response """ - response_data = {'data': data} + response_data = {"data": data} if message: - response_data['message'] = message - + response_data["message"] = message + response = jsonify(response_data) response.status_code = 201 - + if location: - response.headers['Location'] = location - + response.headers["Location"] = location + return response def no_content_response() -> Response: """ Create a 204 No Content response. - + Returns: Flask response """ - return '', 204 - + return "", 204 diff --git a/app/utils/audit.py b/app/utils/audit.py index f6a62a8d..a8973784 100644 --- a/app/utils/audit.py +++ b/app/utils/audit.py @@ -16,52 +16,55 @@ logger = logging.getLogger(__name__) + # Lazy import to avoid circular dependencies def get_audit_log_model(): """Get AuditLog model with lazy import""" from app.models.audit_log import AuditLog + return AuditLog + # Cache to track if audit_logs table exists _audit_table_exists = None # Models that should be tracked for audit logging TRACKED_MODELS = [ - 'Project', - 'Task', - 'TimeEntry', - 'Invoice', - 'InvoiceItem', - 'Client', - 'User', - 'Expense', - 'Payment', - 'Settings', - 'Comment', - 'ProjectCost', - 'KanbanColumn', - 'TimeEntryTemplate', - 'ClientNote', - 'WeeklyTimeGoal', - 'CalendarEvent', - 'BudgetAlert', - 'ExtraGood', - 'Mileage', - 'PerDiem', - 'RateOverride', - 'SavedFilter', - 'InvoiceTemplate', - 'InvoicePDFTemplate', - 'ClientPrepaidConsumption', + "Project", + "Task", + "TimeEntry", + "Invoice", + "InvoiceItem", + "Client", + "User", + "Expense", + "Payment", + "Settings", + "Comment", + "ProjectCost", + "KanbanColumn", + "TimeEntryTemplate", + "ClientNote", + "WeeklyTimeGoal", + "CalendarEvent", + "BudgetAlert", + "ExtraGood", + "Mileage", + "PerDiem", + "RateOverride", + "SavedFilter", + "InvoiceTemplate", + "InvoicePDFTemplate", + "ClientPrepaidConsumption", ] # Fields to exclude from audit logging (internal/system fields) EXCLUDED_FIELDS = { - 'id', - 'created_at', - 'updated_at', - 'password_hash', # Never log passwords - 'password', # Never log passwords + "id", + "created_at", + "updated_at", + "password_hash", # Never log passwords + "password", # Never log passwords } @@ -79,23 +82,23 @@ def get_request_info(): """Get request information for audit logging""" if not has_request_context(): return None, None, None - + ip_address = request.remote_addr - user_agent = request.headers.get('User-Agent') + user_agent = request.headers.get("User-Agent") request_path = request.path - + return ip_address, user_agent, request_path def get_entity_name(instance): """Get a human-readable name for an entity instance""" # Try common name fields - for field in ['name', 'title', 'username', 'email', 'invoice_number']: + for field in ["name", "title", "username", "email", "invoice_number"]: if hasattr(instance, field): value = getattr(instance, field) if value: return str(value) - + # Fallback to string representation return str(instance) @@ -119,83 +122,86 @@ def serialize_value(value): """Serialize a value for storage in audit log""" if value is None: return None - + # Handle datetime objects from datetime import datetime + if isinstance(value, datetime): return value.isoformat() - + # Handle Decimal from decimal import Decimal + if isinstance(value, Decimal): return str(value) - + # Handle boolean if isinstance(value, bool): return value - + # Handle lists and dicts if isinstance(value, (list, dict)): import json + try: return json.dumps(value) except (TypeError, ValueError): return str(value) - + # For everything else, convert to string return str(value) -@event.listens_for(Session, 'after_flush', once=False) +@event.listens_for(Session, "after_flush", once=False) def receive_after_flush(session, flush_context): """Track changes after flush but before commit""" try: # Check if audit_logs table exists before trying to log # Force check every 100 calls to allow for table creation after migration - if not hasattr(receive_after_flush, '_call_count'): + if not hasattr(receive_after_flush, "_call_count"): receive_after_flush._call_count = 0 receive_after_flush._call_count += 1 - + # Force check every 100 calls or if cache is None - force_check = (receive_after_flush._call_count % 100 == 0) + force_check = receive_after_flush._call_count % 100 == 0 if not check_audit_table_exists(force_check=force_check): return - + user_id = get_current_user_id() ip_address, user_agent, request_path = get_request_info() - + # Track inserts (creates) for instance in session.new: if should_track_model(instance): entity_type = get_entity_type(instance) - entity_id = instance.id if hasattr(instance, 'id') else None + entity_id = instance.id if hasattr(instance, "id") else None entity_name = get_entity_name(instance) - + # Log creation AuditLog = get_audit_log_model() AuditLog.log_change( user_id=user_id, - action='created', + action="created", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, change_description=f"Created {entity_type.lower()} '{entity_name}'", ip_address=ip_address, user_agent=user_agent, - request_path=request_path + request_path=request_path, ) - + # Track updates for instance in session.dirty: if should_track_model(instance): entity_type = get_entity_type(instance) - entity_id = instance.id if hasattr(instance, 'id') else None + entity_id = instance.id if hasattr(instance, "id") else None entity_name = get_entity_name(instance) - + # Get the instance state using SQLAlchemy inspect try: instance_state = inspect(instance) - + # Track individual field changes changed_fields = [] for attr_name in instance_state.mapper.column_attrs.keys(): @@ -205,44 +211,40 @@ def receive_after_flush(session, flush_context): if history.has_changes(): old_value = history.deleted[0] if history.deleted else None new_value = history.added[0] if history.added else None - + if old_value != new_value: - changed_fields.append({ - 'field': attr_name, - 'old': old_value, - 'new': new_value - }) - + changed_fields.append({"field": attr_name, "old": old_value, "new": new_value}) + # Log each field change separately for detailed audit trail AuditLog = get_audit_log_model() if changed_fields: for change in changed_fields: AuditLog.log_change( user_id=user_id, - action='updated', + action="updated", entity_type=entity_type, entity_id=entity_id, - field_name=change['field'], - old_value=serialize_value(change['old']), - new_value=serialize_value(change['new']), + field_name=change["field"], + old_value=serialize_value(change["old"]), + new_value=serialize_value(change["new"]), entity_name=entity_name, change_description=f"Updated {entity_type.lower()} '{entity_name}': {change['field']}", ip_address=ip_address, user_agent=user_agent, - request_path=request_path + request_path=request_path, ) else: # Fallback: log update without field details if history is not available AuditLog.log_change( user_id=user_id, - action='updated', + action="updated", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, change_description=f"Updated {entity_type.lower()} '{entity_name}'", ip_address=ip_address, user_agent=user_agent, - request_path=request_path + request_path=request_path, ) except Exception as e: # Fallback: log update without field details if inspection fails @@ -250,37 +252,37 @@ def receive_after_flush(session, flush_context): AuditLog = get_audit_log_model() AuditLog.log_change( user_id=user_id, - action='updated', + action="updated", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, change_description=f"Updated {entity_type.lower()} '{entity_name}'", ip_address=ip_address, user_agent=user_agent, - request_path=request_path + request_path=request_path, ) - + # Track deletes for instance in session.deleted: if should_track_model(instance): entity_type = get_entity_type(instance) - entity_id = instance.id if hasattr(instance, 'id') else None + entity_id = instance.id if hasattr(instance, "id") else None entity_name = get_entity_name(instance) - + # Log deletion AuditLog = get_audit_log_model() AuditLog.log_change( user_id=user_id, - action='deleted', + action="deleted", entity_type=entity_type, entity_id=entity_id, entity_name=entity_name, change_description=f"Deleted {entity_type.lower()} '{entity_name}'", ip_address=ip_address, user_agent=user_agent, - request_path=request_path + request_path=request_path, ) - + except Exception as e: # Don't let audit logging break the main flow logger.error(f"Error in audit logging: {e}", exc_info=True) @@ -288,28 +290,28 @@ def receive_after_flush(session, flush_context): def check_audit_table_exists(force_check=False): """Check if the audit_logs table exists - + Args: force_check: If True, force a fresh check even if cached """ global _audit_table_exists - + # Return cached value if available and not forcing a check if not force_check and _audit_table_exists is not None: return _audit_table_exists - + try: # Try to check if the table exists inspector = sqlalchemy_inspect(db.engine) tables = inspector.get_table_names() - exists = 'audit_logs' in tables + exists = "audit_logs" in tables _audit_table_exists = exists - + if not exists: logger.debug("audit_logs table does not exist - audit logging disabled") else: logger.debug("audit_logs table exists - audit logging enabled") - + return exists except Exception as e: # If we can't check, log it and assume it doesn't exist to be safe @@ -333,4 +335,3 @@ def track_model_changes(model_class): if model_class.__name__ not in TRACKED_MODELS: TRACKED_MODELS.append(model_class.__name__) return model_class - diff --git a/app/utils/backup.py b/app/utils/backup.py index 1172d4be..dd77d5e3 100644 --- a/app/utils/backup.py +++ b/app/utils/backup.py @@ -11,8 +11,8 @@ def _get_backup_root_dir(app): """Compute the absolute backups directory path (project_root/backups).""" - project_root = os.path.abspath(os.path.join(app.root_path, '..')) - backups_dir = os.path.join(project_root, 'backups') + project_root = os.path.abspath(os.path.join(app.root_path, "..")) + backups_dir = os.path.join(project_root, "backups") os.makedirs(backups_dir, exist_ok=True) return backups_dir @@ -21,7 +21,7 @@ def _now_timestamp(): """Return a human-readable local timestamp for file names.""" # Respect user's preference to use local time across the project # rather than UTC for user-facing timestamps. - return datetime.now().strftime('%Y%m%d_%H%M%S') + return datetime.now().strftime("%Y%m%d_%H%M%S") def _detect_db_type_and_path(app): @@ -31,20 +31,21 @@ def _detect_db_type_and_path(app): uri: full SQLAlchemy database URI sqlite_path: file path if sqlite, otherwise None """ - uri = app.config.get('SQLALCHEMY_DATABASE_URI', '') or '' - if isinstance(uri, str) and uri.startswith('sqlite:///'): - return 'sqlite', uri, uri.replace('sqlite:///', '') - if isinstance(uri, str) and (uri.startswith('postgresql') or uri.startswith('postgres')): - return 'postgresql', uri, None + uri = app.config.get("SQLALCHEMY_DATABASE_URI", "") or "" + if isinstance(uri, str) and uri.startswith("sqlite:///"): + return "sqlite", uri, uri.replace("sqlite:///", "") + if isinstance(uri, str) and (uri.startswith("postgresql") or uri.startswith("postgres")): + return "postgresql", uri, None # Default/fallback - return 'unknown', uri, None + return "unknown", uri, None def _get_alembic_revision(db_session): """Return current alembic revision string or None if unavailable.""" try: from sqlalchemy import text - result = db_session.execute(text('SELECT version_num FROM alembic_version')) + + result = db_session.execute(text("SELECT version_num FROM alembic_version")) row = result.first() return row[0] if row else None except Exception: @@ -52,8 +53,8 @@ def _get_alembic_revision(db_session): def _write_manifest(zf, manifest: dict): - data = json.dumps(manifest, indent=2, sort_keys=True).encode('utf-8') - zf.writestr('manifest.json', data) + data = json.dumps(manifest, indent=2, sort_keys=True).encode("utf-8") + zf.writestr("manifest.json", data) def _add_directory_to_zip(zf, source_dir: str, arc_prefix: str): @@ -87,44 +88,50 @@ def create_backup(app) -> str: db_type, db_uri, sqlite_path = _detect_db_type_and_path(app) # Prepare temporary directory for DB dumps if needed - tmp_dir = tempfile.mkdtemp(prefix='tt_backup_') + tmp_dir = tempfile.mkdtemp(prefix="tt_backup_") tmp_db_artifact = None try: # Create DB artifact - if db_type == 'sqlite' and sqlite_path and os.path.exists(sqlite_path): - tmp_db_artifact = os.path.join(tmp_dir, 'db.sqlite') + if db_type == "sqlite" and sqlite_path and os.path.exists(sqlite_path): + tmp_db_artifact = os.path.join(tmp_dir, "db.sqlite") shutil.copy2(sqlite_path, tmp_db_artifact) - elif db_type == 'postgresql': + elif db_type == "postgresql": # Use parsed connection parameters (avoid SQLAlchemy driver suffix in URI) - database_url = os.getenv('DATABASE_URL', db_uri) + database_url = os.getenv("DATABASE_URL", db_uri) parsed = urlparse(database_url) if database_url else None - host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db')) - port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432'))) - user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker')) - password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker')) - dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker')) + host = parsed.hostname if parsed and parsed.hostname else os.getenv("POSTGRES_HOST", "db") + port = parsed.port if parsed and parsed.port else int(os.getenv("POSTGRES_PORT", "5432")) + user = parsed.username if parsed and parsed.username else os.getenv("POSTGRES_USER", "timetracker") + password = parsed.password if parsed and parsed.password else os.getenv("POSTGRES_PASSWORD", "timetracker") + dbname = parsed.path.lstrip("/") if parsed and parsed.path else os.getenv("POSTGRES_DB", "timetracker") - tmp_db_artifact = os.path.join(tmp_dir, 'db.dump') + tmp_db_artifact = os.path.join(tmp_dir, "db.dump") pg_dump_cmd = [ - 'pg_dump', - '--format=custom', - '-h', host, - '-p', str(port), - '-U', user, - '-d', dbname, - f'--file={tmp_db_artifact}', + "pg_dump", + "--format=custom", + "-h", + host, + "-p", + str(port), + "-U", + user, + "-d", + dbname, + f"--file={tmp_db_artifact}", ] env = os.environ.copy() if password: - env['PGPASSWORD'] = str(password) + env["PGPASSWORD"] = str(password) try: - completed = subprocess.run(pg_dump_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) + completed = subprocess.run( + pg_dump_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env + ) except FileNotFoundError: - raise RuntimeError('pg_dump not found. Please ensure PostgreSQL client tools are installed.') + raise RuntimeError("pg_dump not found. Please ensure PostgreSQL client tools are installed.") except subprocess.CalledProcessError as e: - stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else '' - raise RuntimeError(f'pg_dump failed: {stderr.strip() or e}') + stderr = e.stderr.decode("utf-8", errors="ignore") if e.stderr else "" + raise RuntimeError(f"pg_dump failed: {stderr.strip() or e}") else: # Best effort: we continue without DB artifact tmp_db_artifact = None @@ -132,10 +139,10 @@ def create_backup(app) -> str: # Gather metadata alembic_rev = _get_alembic_revision(db.session) manifest = { - 'created_at': datetime.now().isoformat(timespec='seconds'), - 'db_type': db_type, - 'alembic_revision': alembic_rev, - 'app_version': None, + "created_at": datetime.now().isoformat(timespec="seconds"), + "db_type": db_type, + "alembic_revision": alembic_rev, + "app_version": None, } # Serialize settings for convenience (DB backup still authoritative) @@ -143,17 +150,17 @@ def create_backup(app) -> str: settings_json = json.dumps(settings_obj.to_dict(), indent=2, sort_keys=True) # Write the zip - with ZipFile(archive_path, mode='w', compression=ZIP_DEFLATED) as zf: + with ZipFile(archive_path, mode="w", compression=ZIP_DEFLATED) as zf: _write_manifest(zf, manifest) if tmp_db_artifact and os.path.exists(tmp_db_artifact): - arc_name = 'db.sqlite' if db_type == 'sqlite' else 'db.dump' + arc_name = "db.sqlite" if db_type == "sqlite" else "db.dump" zf.write(tmp_db_artifact, arc_name) - zf.writestr('settings.json', settings_json.encode('utf-8')) + zf.writestr("settings.json", settings_json.encode("utf-8")) # Include uploads (e.g., logos) - uploads_root = os.path.join(app.root_path, 'static', 'uploads') - _add_directory_to_zip(zf, uploads_root, 'uploads') + uploads_root = os.path.join(app.root_path, "static", "uploads") + _add_directory_to_zip(zf, uploads_root, "uploads") return archive_path finally: @@ -181,7 +188,7 @@ def restore_backup(app, archive_path: str, progress_callback=None) -> tuple[bool return False, f"Backup archive not found: {archive_path}" db_type, db_uri, sqlite_path = _detect_db_type_and_path(app) - tmp_dir = tempfile.mkdtemp(prefix='tt_restore_') + tmp_dir = tempfile.mkdtemp(prefix="tt_restore_") def _progress(label: str, percent: int): try: @@ -192,15 +199,15 @@ def _progress(label: str, percent: int): try: # Extract archive - with ZipFile(archive_path, mode='r') as zf: + with ZipFile(archive_path, mode="r") as zf: zf.extractall(tmp_dir) - _progress('Archive extracted', 10) + _progress("Archive extracted", 10) # Read manifest (optional) - manifest_path = os.path.join(tmp_dir, 'manifest.json') + manifest_path = os.path.join(tmp_dir, "manifest.json") if os.path.exists(manifest_path): try: - with open(manifest_path, 'r', encoding='utf-8') as f: + with open(manifest_path, "r", encoding="utf-8") as f: _ = json.load(f) except Exception: pass @@ -214,13 +221,13 @@ def _progress(label: str, percent: int): pass # Restore DB - if db_type == 'sqlite': - src_sqlite = os.path.join(tmp_dir, 'db.sqlite') + if db_type == "sqlite": + src_sqlite = os.path.join(tmp_dir, "db.sqlite") if not os.path.exists(src_sqlite): - return False, 'Backup does not contain db.sqlite for SQLite restore' + return False, "Backup does not contain db.sqlite for SQLite restore" if not sqlite_path: - return False, 'Current configuration is not SQLite or path not found' + return False, "Current configuration is not SQLite or path not found" # Ensure destination directory exists dest_dir = os.path.dirname(sqlite_path) @@ -229,7 +236,7 @@ def _progress(label: str, percent: int): # Safety copy of current DB if exists if os.path.exists(sqlite_path): - safety_copy = sqlite_path + f'.bak_{_now_timestamp()}' + safety_copy = sqlite_path + f".bak_{_now_timestamp()}" shutil.copy2(sqlite_path, safety_copy) # Replace DB file @@ -245,71 +252,76 @@ def _progress(label: str, percent: int): last_err = e sleep(0.2) if last_err: - return False, f'Failed to write SQLite database file: {last_err}' - _progress('SQLite database restored', 60) + return False, f"Failed to write SQLite database file: {last_err}" + _progress("SQLite database restored", 60) - elif db_type == 'postgresql': - src_dump = os.path.join(tmp_dir, 'db.dump') + elif db_type == "postgresql": + src_dump = os.path.join(tmp_dir, "db.dump") if not os.path.exists(src_dump): - return False, 'Backup does not contain db.dump for PostgreSQL restore' + return False, "Backup does not contain db.dump for PostgreSQL restore" - database_url = os.getenv('DATABASE_URL', db_uri) + database_url = os.getenv("DATABASE_URL", db_uri) parsed = urlparse(database_url) if database_url else None - host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db')) - port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432'))) - user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker')) - password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker')) - dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker')) + host = parsed.hostname if parsed and parsed.hostname else os.getenv("POSTGRES_HOST", "db") + port = parsed.port if parsed and parsed.port else int(os.getenv("POSTGRES_PORT", "5432")) + user = parsed.username if parsed and parsed.username else os.getenv("POSTGRES_USER", "timetracker") + password = parsed.password if parsed and parsed.password else os.getenv("POSTGRES_PASSWORD", "timetracker") + dbname = parsed.path.lstrip("/") if parsed and parsed.path else os.getenv("POSTGRES_DB", "timetracker") pg_restore_cmd = [ - 'pg_restore', - '--clean', - '--if-exists', - '--no-owner', - '-h', host, - '-p', str(port), - '-U', user, - '-d', dbname, + "pg_restore", + "--clean", + "--if-exists", + "--no-owner", + "-h", + host, + "-p", + str(port), + "-U", + user, + "-d", + dbname, src_dump, ] env = os.environ.copy() if password: - env['PGPASSWORD'] = str(password) + env["PGPASSWORD"] = str(password) try: - _progress('Restoring PostgreSQL database', 20) + _progress("Restoring PostgreSQL database", 20) subprocess.run(pg_restore_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env) except FileNotFoundError: - return False, 'pg_restore not found. Please install PostgreSQL client tools.' + return False, "pg_restore not found. Please install PostgreSQL client tools." except subprocess.CalledProcessError as e: - stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else '' - return False, f'pg_restore failed: {stderr.strip() or e}' - _progress('PostgreSQL database restored', 60) + stderr = e.stderr.decode("utf-8", errors="ignore") if e.stderr else "" + return False, f"pg_restore failed: {stderr.strip() or e}" + _progress("PostgreSQL database restored", 60) else: - return False, 'Unsupported or unknown database type for restore' + return False, "Unsupported or unknown database type for restore" # Restore uploads - extracted_uploads = os.path.join(tmp_dir, 'uploads') + extracted_uploads = os.path.join(tmp_dir, "uploads") if os.path.isdir(extracted_uploads): - target_uploads = os.path.join(app.root_path, 'static', 'uploads') + target_uploads = os.path.join(app.root_path, "static", "uploads") os.makedirs(target_uploads, exist_ok=True) # Merge copy for root, _, files in os.walk(extracted_uploads): rel = os.path.relpath(root, extracted_uploads) - target_dir = os.path.join(target_uploads, rel) if rel != '.' else target_uploads + target_dir = os.path.join(target_uploads, rel) if rel != "." else target_uploads os.makedirs(target_dir, exist_ok=True) for fn in files: shutil.copy2(os.path.join(root, fn), os.path.join(target_dir, fn)) - _progress('Uploads restored', 80) + _progress("Uploads restored", 80) # Run migrations to ensure compatibility with current code try: from flask_migrate import upgrade as alembic_upgrade + with app.app_context(): - _progress('Running migrations', 90) + _progress("Running migrations", 90) alembic_upgrade() except Exception as e: # If migrations fail, report failure to caller for visibility - return False, f'Restore completed but migration failed: {e}' + return False, f"Restore completed but migration failed: {e}" # Dispose connections once more after restore/migrate to ensure clean state try: @@ -319,12 +331,10 @@ def _progress(label: str, percent: int): except Exception: pass - _progress('Restore completed successfully', 100) - return True, 'Restore completed successfully' + _progress("Restore completed successfully", 100) + return True, "Restore completed successfully" finally: try: shutil.rmtree(tmp_dir, ignore_errors=True) except Exception: pass - - diff --git a/app/utils/budget_forecasting.py b/app/utils/budget_forecasting.py index 7bb5f4df..f8f49cbb 100644 --- a/app/utils/budget_forecasting.py +++ b/app/utils/budget_forecasting.py @@ -18,11 +18,11 @@ def calculate_burn_rate(project_id: int, days: int = 30) -> Dict: """ Calculate the burn rate for a project based on recent activity. - + Args: project_id: ID of the project days: Number of days to analyze (default: 30) - + Returns: Dictionary with burn rate metrics: - daily_burn_rate: Average daily cost @@ -34,62 +34,57 @@ def calculate_burn_rate(project_id: int, days: int = 30) -> Dict: project = Project.query.get(project_id) if not project: return None - + end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Calculate time-based costs time_entries = TimeEntry.query.filter( TimeEntry.project_id == project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True, func.date(TimeEntry.start_time) >= start_date, - func.date(TimeEntry.start_time) <= end_date + func.date(TimeEntry.start_time) <= end_date, ).all() - - time_cost = Decimal('0') - hourly_rate = project.hourly_rate or Decimal('0') - + + time_cost = Decimal("0") + hourly_rate = project.hourly_rate or Decimal("0") + for entry in time_entries: if entry.duration_seconds is None: continue hours = Decimal(str(entry.duration_seconds / 3600)) time_cost += hours * hourly_rate - + # Calculate direct costs - direct_costs = ProjectCost.get_total_costs( - project_id, - start_date=start_date, - end_date=end_date, - billable_only=True - ) - + direct_costs = ProjectCost.get_total_costs(project_id, start_date=start_date, end_date=end_date, billable_only=True) + total_cost = float(time_cost) + direct_costs - + # Calculate rates daily_burn_rate = total_cost / days if days > 0 else 0 weekly_burn_rate = daily_burn_rate * 7 monthly_burn_rate = daily_burn_rate * 30 - + return { - 'daily_burn_rate': round(daily_burn_rate, 2), - 'weekly_burn_rate': round(weekly_burn_rate, 2), - 'monthly_burn_rate': round(monthly_burn_rate, 2), - 'period_total': round(total_cost, 2), - 'period_days': days, - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat() + "daily_burn_rate": round(daily_burn_rate, 2), + "weekly_burn_rate": round(weekly_burn_rate, 2), + "monthly_burn_rate": round(monthly_burn_rate, 2), + "period_total": round(total_cost, 2), + "period_days": days, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), } def estimate_completion_date(project_id: int, analysis_days: int = 30) -> Dict: """ Estimate project completion date based on burn rate and remaining budget. - + Args: project_id: ID of the project analysis_days: Number of days to analyze for burn rate (default: 30) - + Returns: Dictionary with completion estimates: - estimated_completion_date: Estimated date when budget will be exhausted @@ -103,64 +98,64 @@ def estimate_completion_date(project_id: int, analysis_days: int = 30) -> Dict: project = Project.query.get(project_id) if not project or not project.budget_amount: return None - + burn_rate = calculate_burn_rate(project_id, analysis_days) - if not burn_rate or burn_rate['daily_burn_rate'] == 0: + if not burn_rate or burn_rate["daily_burn_rate"] == 0: return { - 'estimated_completion_date': None, - 'days_remaining': None, - 'budget_amount': float(project.budget_amount), - 'consumed_amount': project.budget_consumed_amount, - 'remaining_budget': float(project.budget_amount) - project.budget_consumed_amount, - 'daily_burn_rate': 0, - 'confidence': 'low', - 'message': 'No recent activity to estimate completion date' + "estimated_completion_date": None, + "days_remaining": None, + "budget_amount": float(project.budget_amount), + "consumed_amount": project.budget_consumed_amount, + "remaining_budget": float(project.budget_amount) - project.budget_consumed_amount, + "daily_burn_rate": 0, + "confidence": "low", + "message": "No recent activity to estimate completion date", } - + budget_amount = float(project.budget_amount) consumed_amount = project.budget_consumed_amount remaining_budget = budget_amount - consumed_amount - - daily_burn = burn_rate['daily_burn_rate'] - + + daily_burn = burn_rate["daily_burn_rate"] + if remaining_budget <= 0: return { - 'estimated_completion_date': datetime.now().date().isoformat(), - 'days_remaining': 0, - 'budget_amount': budget_amount, - 'consumed_amount': consumed_amount, - 'remaining_budget': remaining_budget, - 'daily_burn_rate': daily_burn, - 'confidence': 'high', - 'message': 'Budget already exhausted' + "estimated_completion_date": datetime.now().date().isoformat(), + "days_remaining": 0, + "budget_amount": budget_amount, + "consumed_amount": consumed_amount, + "remaining_budget": remaining_budget, + "daily_burn_rate": daily_burn, + "confidence": "high", + "message": "Budget already exhausted", } - + days_remaining = int(remaining_budget / daily_burn) if daily_burn > 0 else 999999 estimated_date = datetime.now().date() + timedelta(days=days_remaining) - + # Calculate confidence based on data consistency confidence = _calculate_confidence(project_id, analysis_days) - + return { - 'estimated_completion_date': estimated_date.isoformat(), - 'days_remaining': days_remaining, - 'budget_amount': budget_amount, - 'consumed_amount': round(consumed_amount, 2), - 'remaining_budget': round(remaining_budget, 2), - 'daily_burn_rate': daily_burn, - 'confidence': confidence, - 'message': f'Based on {analysis_days} days of activity' + "estimated_completion_date": estimated_date.isoformat(), + "days_remaining": days_remaining, + "budget_amount": budget_amount, + "consumed_amount": round(consumed_amount, 2), + "remaining_budget": round(remaining_budget, 2), + "daily_burn_rate": daily_burn, + "confidence": confidence, + "message": f"Based on {analysis_days} days of activity", } def analyze_resource_allocation(project_id: int, days: int = 30) -> Dict: """ Analyze resource allocation and costs per team member. - + Args: project_id: ID of the project days: Number of days to analyze (default: 30) - + Returns: Dictionary with resource allocation data: - users: List of users with their hours and costs @@ -171,74 +166,82 @@ def analyze_resource_allocation(project_id: int, days: int = 30) -> Dict: project = Project.query.get(project_id) if not project: return None - + end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Query time entries by user - user_data = db.session.query( - User.id, - User.username, - User.full_name, - func.sum(TimeEntry.duration_seconds).label('total_seconds'), - func.count(TimeEntry.id).label('entry_count') - ).join(TimeEntry).filter( - TimeEntry.project_id == project_id, - TimeEntry.end_time.isnot(None), - TimeEntry.billable == True, - func.date(TimeEntry.start_time) >= start_date, - func.date(TimeEntry.start_time) <= end_date - ).group_by(User.id, User.username, User.full_name).all() - + user_data = ( + db.session.query( + User.id, + User.username, + User.full_name, + func.sum(TimeEntry.duration_seconds).label("total_seconds"), + func.count(TimeEntry.id).label("entry_count"), + ) + .join(TimeEntry) + .filter( + TimeEntry.project_id == project_id, + TimeEntry.end_time.isnot(None), + TimeEntry.billable == True, + func.date(TimeEntry.start_time) >= start_date, + func.date(TimeEntry.start_time) <= end_date, + ) + .group_by(User.id, User.username, User.full_name) + .all() + ) + users = [] total_hours = 0 total_cost = 0 - + hourly_rate = float(project.hourly_rate or 0) - + for user_id, username, full_name, total_seconds, entry_count in user_data: hours = total_seconds / 3600 cost = hours * hourly_rate total_hours += hours total_cost += cost - - users.append({ - 'user_id': user_id, - 'username': full_name if full_name else username, - 'hours': round(hours, 2), - 'cost': round(cost, 2), - 'entry_count': entry_count, - 'average_hours_per_entry': round(hours / entry_count, 2) if entry_count > 0 else 0 - }) - + + users.append( + { + "user_id": user_id, + "username": full_name if full_name else username, + "hours": round(hours, 2), + "cost": round(cost, 2), + "entry_count": entry_count, + "average_hours_per_entry": round(hours / entry_count, 2) if entry_count > 0 else 0, + } + ) + # Calculate cost distribution percentages for user in users: - user['cost_percentage'] = round((user['cost'] / total_cost * 100), 1) if total_cost > 0 else 0 - user['hours_percentage'] = round((user['hours'] / total_hours * 100), 1) if total_hours > 0 else 0 - + user["cost_percentage"] = round((user["cost"] / total_cost * 100), 1) if total_cost > 0 else 0 + user["hours_percentage"] = round((user["hours"] / total_hours * 100), 1) if total_hours > 0 else 0 + # Sort by cost (highest first) - users.sort(key=lambda x: x['cost'], reverse=True) - + users.sort(key=lambda x: x["cost"], reverse=True) + return { - 'users': users, - 'total_hours': round(total_hours, 2), - 'total_cost': round(total_cost, 2), - 'period_days': days, - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat(), - 'hourly_rate': hourly_rate + "users": users, + "total_hours": round(total_hours, 2), + "total_cost": round(total_cost, 2), + "period_days": days, + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), + "hourly_rate": hourly_rate, } -def analyze_cost_trends(project_id: int, days: int = 90, granularity: str = 'week') -> Dict: +def analyze_cost_trends(project_id: int, days: int = 90, granularity: str = "week") -> Dict: """ Analyze cost trends over time for a project. - + Args: project_id: ID of the project days: Number of days to analyze (default: 90) granularity: 'day', 'week', or 'month' (default: 'week') - + Returns: Dictionary with trend data: - periods: List of time periods with costs @@ -249,32 +252,32 @@ def analyze_cost_trends(project_id: int, days: int = 90, granularity: str = 'wee project = Project.query.get(project_id) if not project: return None - + end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + # Get all time entries time_entries = TimeEntry.query.filter( TimeEntry.project_id == project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True, func.date(TimeEntry.start_time) >= start_date, - func.date(TimeEntry.start_time) <= end_date + func.date(TimeEntry.start_time) <= end_date, ).all() - + # Get all project costs project_costs = ProjectCost.query.filter( ProjectCost.project_id == project_id, ProjectCost.billable == True, ProjectCost.cost_date >= start_date, - ProjectCost.cost_date <= end_date + ProjectCost.cost_date <= end_date, ).all() - + hourly_rate = float(project.hourly_rate or 0) - + # Group by period period_costs = defaultdict(float) - + for entry in time_entries: if entry.duration_seconds is None: continue @@ -282,68 +285,62 @@ def analyze_cost_trends(project_id: int, days: int = 90, granularity: str = 'wee hours = entry.duration_seconds / 3600 cost = hours * hourly_rate period_costs[period_key] += cost - + for cost in project_costs: period_key = _get_period_key(cost.cost_date, granularity) period_costs[period_key] += float(cost.amount) - + # Sort periods chronologically sorted_periods = sorted(period_costs.items()) - - periods = [ - { - 'period': period, - 'cost': round(cost, 2) - } - for period, cost in sorted_periods - ] - + + periods = [{"period": period, "cost": round(cost, 2)} for period, cost in sorted_periods] + # Calculate trend metrics if len(periods) >= 2: - first_cost = periods[0]['cost'] - last_cost = periods[-1]['cost'] - + first_cost = periods[0]["cost"] + last_cost = periods[-1]["cost"] + if first_cost > 0: trend_percentage = ((last_cost - first_cost) / first_cost) * 100 else: trend_percentage = 0 - + # Determine trend direction - costs_list = [p['cost'] for p in periods] - avg_first_half = statistics.mean(costs_list[:len(costs_list)//2]) if len(costs_list) >= 2 else 0 - avg_second_half = statistics.mean(costs_list[len(costs_list)//2:]) if len(costs_list) >= 2 else 0 - + costs_list = [p["cost"] for p in periods] + avg_first_half = statistics.mean(costs_list[: len(costs_list) // 2]) if len(costs_list) >= 2 else 0 + avg_second_half = statistics.mean(costs_list[len(costs_list) // 2 :]) if len(costs_list) >= 2 else 0 + if avg_second_half > avg_first_half * 1.1: - trend_direction = 'increasing' + trend_direction = "increasing" elif avg_second_half < avg_first_half * 0.9: - trend_direction = 'decreasing' + trend_direction = "decreasing" else: - trend_direction = 'stable' + trend_direction = "stable" else: trend_percentage = 0 - trend_direction = 'insufficient_data' - - average_cost = statistics.mean([p['cost'] for p in periods]) if periods else 0 - + trend_direction = "insufficient_data" + + average_cost = statistics.mean([p["cost"] for p in periods]) if periods else 0 + return { - 'periods': periods, - 'trend_direction': trend_direction, - 'average_cost_per_period': round(average_cost, 2), - 'trend_percentage': round(trend_percentage, 1), - 'granularity': granularity, - 'period_count': len(periods), - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat() + "periods": periods, + "trend_direction": trend_direction, + "average_cost_per_period": round(average_cost, 2), + "trend_percentage": round(trend_percentage, 1), + "granularity": granularity, + "period_count": len(periods), + "start_date": start_date.isoformat(), + "end_date": end_date.isoformat(), } def get_budget_status(project_id: int) -> Dict: """ Get comprehensive budget status for a project. - + Args: project_id: ID of the project - + Returns: Dictionary with budget status: - budget_amount: Total budget @@ -356,45 +353,45 @@ def get_budget_status(project_id: int) -> Dict: project = Project.query.get(project_id) if not project or not project.budget_amount: return None - + budget_amount = float(project.budget_amount) consumed_amount = project.budget_consumed_amount remaining_amount = budget_amount - consumed_amount consumed_percentage = (consumed_amount / budget_amount * 100) if budget_amount > 0 else 0 - + threshold_percent = project.budget_threshold_percent or 80 - + # Determine status if consumed_percentage >= 100: - status = 'over_budget' + status = "over_budget" elif consumed_percentage >= threshold_percent: - status = 'critical' + status = "critical" elif consumed_percentage >= threshold_percent * 0.75: - status = 'warning' + status = "warning" else: - status = 'healthy' - + status = "healthy" + return { - 'budget_amount': budget_amount, - 'consumed_amount': round(consumed_amount, 2), - 'remaining_amount': round(remaining_amount, 2), - 'consumed_percentage': round(consumed_percentage, 1), - 'status': status, - 'threshold_percent': threshold_percent, - 'project_name': project.name, - 'project_id': project_id + "budget_amount": budget_amount, + "consumed_amount": round(consumed_amount, 2), + "remaining_amount": round(remaining_amount, 2), + "consumed_percentage": round(consumed_percentage, 1), + "status": status, + "threshold_percent": threshold_percent, + "project_name": project.name, + "project_id": project_id, } def _get_period_key(date_obj: date, granularity: str) -> str: """Get period key based on granularity.""" - if granularity == 'day': + if granularity == "day": return date_obj.isoformat() - elif granularity == 'week': + elif granularity == "week": # Get ISO week number year, week, _ = date_obj.isocalendar() return f"{year}-W{week:02d}" - elif granularity == 'month': + elif granularity == "month": return f"{date_obj.year}-{date_obj.month:02d}" else: return date_obj.isoformat() @@ -403,141 +400,140 @@ def _get_period_key(date_obj: date, granularity: str) -> str: def _calculate_confidence(project_id: int, days: int) -> str: """ Calculate confidence level for predictions based on data consistency. - + Returns: 'high', 'medium', or 'low' """ # Get daily costs for the period end_date = datetime.now().date() start_date = end_date - timedelta(days=days) - + project = Project.query.get(project_id) hourly_rate = float(project.hourly_rate or 0) - + # Group by day daily_costs = defaultdict(float) - + time_entries = TimeEntry.query.filter( TimeEntry.project_id == project_id, TimeEntry.end_time.isnot(None), TimeEntry.billable == True, func.date(TimeEntry.start_time) >= start_date, - func.date(TimeEntry.start_time) <= end_date + func.date(TimeEntry.start_time) <= end_date, ).all() - + for entry in time_entries: if entry.duration_seconds is None: continue day = entry.start_time.date() hours = entry.duration_seconds / 3600 daily_costs[day] += hours * hourly_rate - + if len(daily_costs) < 7: - return 'low' - + return "low" + costs_list = list(daily_costs.values()) - + if len(costs_list) < 2: - return 'low' - + return "low" + # Calculate coefficient of variation mean_cost = statistics.mean(costs_list) if mean_cost == 0: - return 'low' - + return "low" + std_dev = statistics.stdev(costs_list) if len(costs_list) > 1 else 0 cv = std_dev / mean_cost - + # Lower CV means more consistent data, higher confidence if cv < 0.5: - return 'high' + return "high" elif cv < 1.0: - return 'medium' + return "medium" else: - return 'low' + return "low" def check_budget_alerts(project_id: int) -> List[Dict]: """ Check if budget alerts should be triggered for a project. - + Args: project_id: ID of the project - + Returns: List of alerts that should be triggered """ from app.models import BudgetAlert - + project = Project.query.get(project_id) if not project or not project.budget_amount: return [] - + budget_status = get_budget_status(project_id) if not budget_status: return [] - + alerts = [] - consumed_percentage = budget_status['consumed_percentage'] - threshold_percent = budget_status['threshold_percent'] - + consumed_percentage = budget_status["consumed_percentage"] + threshold_percent = budget_status["threshold_percent"] + # Check for 80% threshold (or custom threshold) if consumed_percentage >= threshold_percent and consumed_percentage < 100: # Check if we already have a recent unacknowledged alert - recent_alert = BudgetAlert.query.filter_by( - project_id=project_id, - alert_type='warning_80', - is_acknowledged=False - ).filter( - BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24) - ).first() - + recent_alert = ( + BudgetAlert.query.filter_by(project_id=project_id, alert_type="warning_80", is_acknowledged=False) + .filter(BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)) + .first() + ) + if not recent_alert: - alerts.append({ - 'type': 'warning_80', - 'project_id': project_id, - 'budget_consumed_percent': consumed_percentage, - 'budget_amount': budget_status['budget_amount'], - 'consumed_amount': budget_status['consumed_amount'] - }) - + alerts.append( + { + "type": "warning_80", + "project_id": project_id, + "budget_consumed_percent": consumed_percentage, + "budget_amount": budget_status["budget_amount"], + "consumed_amount": budget_status["consumed_amount"], + } + ) + # Check for 100% budget reached if consumed_percentage >= 100 and consumed_percentage < 105: - recent_alert = BudgetAlert.query.filter_by( - project_id=project_id, - alert_type='warning_100', - is_acknowledged=False - ).filter( - BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24) - ).first() - + recent_alert = ( + BudgetAlert.query.filter_by(project_id=project_id, alert_type="warning_100", is_acknowledged=False) + .filter(BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)) + .first() + ) + if not recent_alert: - alerts.append({ - 'type': 'warning_100', - 'project_id': project_id, - 'budget_consumed_percent': consumed_percentage, - 'budget_amount': budget_status['budget_amount'], - 'consumed_amount': budget_status['consumed_amount'] - }) - + alerts.append( + { + "type": "warning_100", + "project_id": project_id, + "budget_consumed_percent": consumed_percentage, + "budget_amount": budget_status["budget_amount"], + "consumed_amount": budget_status["consumed_amount"], + } + ) + # Check for over budget if consumed_percentage >= 105: - recent_alert = BudgetAlert.query.filter_by( - project_id=project_id, - alert_type='over_budget', - is_acknowledged=False - ).filter( - BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24) - ).first() - + recent_alert = ( + BudgetAlert.query.filter_by(project_id=project_id, alert_type="over_budget", is_acknowledged=False) + .filter(BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)) + .first() + ) + if not recent_alert: - alerts.append({ - 'type': 'over_budget', - 'project_id': project_id, - 'budget_consumed_percent': consumed_percentage, - 'budget_amount': budget_status['budget_amount'], - 'consumed_amount': budget_status['consumed_amount'] - }) - - return alerts + alerts.append( + { + "type": "over_budget", + "project_id": project_id, + "budget_consumed_percent": consumed_percentage, + "budget_amount": budget_status["budget_amount"], + "consumed_amount": budget_status["consumed_amount"], + } + ) + return alerts diff --git a/app/utils/cache.py b/app/utils/cache.py index ef5ce1ab..75909b89 100644 --- a/app/utils/cache.py +++ b/app/utils/cache.py @@ -12,48 +12,48 @@ class Cache: """Simple in-memory cache (can be replaced with Redis)""" - + def __init__(self): self._cache: Dict[str, tuple[Any, float]] = {} self._default_ttl = 3600 # 1 hour - + def get(self, key: str) -> Optional[Any]: """Get a value from cache""" if key not in self._cache: return None - + value, expiry = self._cache[key] if time.time() > expiry: del self._cache[key] return None - + return value - + def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None: """Set a value in cache""" ttl = ttl or self._default_ttl expiry = time.time() + ttl self._cache[key] = (value, expiry) - + def delete(self, key: str) -> None: """Delete a value from cache""" if key in self._cache: del self._cache[key] - + def clear(self) -> None: """Clear all cache""" self._cache.clear() - + def exists(self, key: str) -> bool: """Check if a key exists in cache""" if key not in self._cache: return False - + _, expiry = self._cache[key] if time.time() > expiry: del self._cache[key] return False - + return True @@ -68,10 +68,7 @@ def get_cache() -> Cache: def cache_key(*args, **kwargs) -> str: """Generate a cache key from arguments""" - key_data = { - 'args': args, - 'kwargs': sorted(kwargs.items()) - } + key_data = {"args": args, "kwargs": sorted(kwargs.items())} key_str = json.dumps(key_data, sort_keys=True, default=str) return hashlib.md5(key_str.encode()).hexdigest() @@ -79,35 +76,37 @@ def cache_key(*args, **kwargs) -> str: def cached(ttl: int = 3600, key_prefix: str = ""): """ Decorator to cache function results. - + Args: ttl: Time to live in seconds key_prefix: Prefix for cache key """ + def decorator(func: Callable) -> Callable: @wraps(func) def wrapper(*args, **kwargs): cache = get_cache() key = f"{key_prefix}:{func.__name__}:{cache_key(*args, **kwargs)}" - + # Try to get from cache cached_value = cache.get(key) if cached_value is not None: return cached_value - + # Call function and cache result result = func(*args, **kwargs) cache.set(key, result, ttl=ttl) return result - + return wrapper + return decorator def invalidate_cache(pattern: str) -> None: """ Invalidate cache entries matching a pattern. - + Note: This is a simple implementation. Redis would use pattern matching. """ cache = get_cache() @@ -119,11 +118,10 @@ def invalidate_cache(pattern: str) -> None: def init_redis_cache(redis_url: Optional[str] = None) -> None: """ Initialize Redis cache (for future use). - + Args: redis_url: Redis connection URL (e.g., redis://localhost:6379/0) """ # This would be implemented when Redis is added # For now, keep using in-memory cache pass - diff --git a/app/utils/cache_redis.py b/app/utils/cache_redis.py index 6e0d315d..d62437ba 100644 --- a/app/utils/cache_redis.py +++ b/app/utils/cache_redis.py @@ -20,6 +20,7 @@ # Try to import redis, but don't fail if not available try: import redis + REDIS_AVAILABLE = True except ImportError: REDIS_AVAILABLE = False @@ -29,15 +30,15 @@ def get_redis_client(): """ Get Redis client instance. - + Returns: Redis client or None if Redis is not configured """ if not REDIS_AVAILABLE: return None - - redis_url = os.getenv('REDIS_URL', 'redis://localhost:6379/0') - + + redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0") + try: client = redis.from_url(redis_url, decode_responses=True) # Test connection @@ -51,46 +52,46 @@ def get_redis_client(): def cache_key(prefix: str, *args, **kwargs) -> str: """ Generate a cache key from prefix and arguments. - + Args: prefix: Cache key prefix *args: Positional arguments **kwargs: Keyword arguments - + Returns: Cache key string """ key_parts = [prefix] - + for arg in args: key_parts.append(str(arg)) - + for key, value in sorted(kwargs.items()): key_parts.append(f"{key}:{value}") - + return ":".join(key_parts) def get_cache(key: str, default: Any = None) -> Optional[Any]: """ Get value from cache. - + Args: key: Cache key default: Default value if key not found - + Returns: Cached value or default """ client = get_redis_client() if not client: return default - + try: value = client.get(key) if value is None: return default - + # Try to deserialize JSON try: return json.loads(value) @@ -104,24 +105,24 @@ def get_cache(key: str, default: Any = None) -> Optional[Any]: def set_cache(key: str, value: Any, ttl: int = 3600) -> bool: """ Set value in cache. - + Args: key: Cache key value: Value to cache ttl: Time to live in seconds (default: 1 hour) - + Returns: True if successful, False otherwise """ client = get_redis_client() if not client: return False - + try: # Serialize value if needed if isinstance(value, (dict, list)): value = json.dumps(value) - + client.setex(key, ttl, value) return True except Exception as e: @@ -132,19 +133,19 @@ def set_cache(key: str, value: Any, ttl: int = 3600) -> bool: def delete_cache(key: str) -> bool: """ Delete value from cache. - + Args: key: Cache key (supports wildcards with *) - + Returns: True if successful, False otherwise """ client = get_redis_client() if not client: return False - + try: - if '*' in key: + if "*" in key: # Delete all keys matching pattern keys = client.keys(key) if keys: @@ -160,17 +161,18 @@ def delete_cache(key: str) -> bool: def cache_result(prefix: str, ttl: int = 3600, key_func: Optional[Callable] = None): """ Decorator to cache function results. - + Args: prefix: Cache key prefix ttl: Time to live in seconds key_func: Optional function to generate cache key from args/kwargs - + Usage: @cache_result('user_projects', ttl=300) def get_user_projects(user_id): ... """ + def decorator(func): @wraps(func) def wrapper(*args, **kwargs): @@ -179,30 +181,32 @@ def wrapper(*args, **kwargs): cache_key_str = key_func(*args, **kwargs) else: cache_key_str = cache_key(prefix, *args, **kwargs) - + # Try to get from cache cached = get_cache(cache_key_str) if cached is not None: return cached - + # Execute function result = func(*args, **kwargs) - + # Cache result set_cache(cache_key_str, result, ttl) - + return result + return wrapper + return decorator def invalidate_cache_pattern(pattern: str): """ Invalidate all cache keys matching a pattern. - + Args: pattern: Cache key pattern (supports *) - + Example: invalidate_cache_pattern('user_projects:*') # Invalidate all user projects """ @@ -212,6 +216,7 @@ def invalidate_cache_pattern(pattern: str): # Cache key prefixes (for consistency) class CacheKeys: """Standard cache key prefixes""" + USER_PROJECTS = "user_projects" PROJECT_DETAILS = "project_details" TASK_LIST = "task_list" @@ -219,4 +224,3 @@ class CacheKeys: SETTINGS = "settings" USER_PREFERENCES = "user_preferences" CLIENT_LIST = "client_list" - diff --git a/app/utils/cli.py b/app/utils/cli.py index e7a24b2c..200e5a5a 100644 --- a/app/utils/cli.py +++ b/app/utils/cli.py @@ -8,40 +8,41 @@ from app.utils.backup import create_backup, restore_backup from app.utils.permissions_seed import seed_all, seed_permissions, seed_roles, migrate_legacy_users + def register_cli_commands(app): """Register CLI commands for the application""" - + @app.cli.command() @with_appcontext def init_db(): """Initialize the database with tables and default data""" from app.models import Settings, User - + # Create all tables db.create_all() - + # Initialize settings if they don't exist if not Settings.query.first(): settings = Settings() db.session.add(settings) db.session.commit() click.echo("Database initialized with default settings") - + # Ensure admin user exists and has role 'admin' - admin_username = os.getenv('ADMIN_USERNAMES', 'admin').split(',')[0].strip().lower() + admin_username = os.getenv("ADMIN_USERNAMES", "admin").split(",")[0].strip().lower() existing = User.query.filter_by(username=admin_username).first() if not existing: - admin_user = User(username=admin_username, role='admin') + admin_user = User(username=admin_username, role="admin") admin_user.is_active = True db.session.add(admin_user) db.session.commit() click.echo(f"Created admin user: {admin_username}") - elif existing.role != 'admin': - existing.role = 'admin' + elif existing.role != "admin": + existing.role = "admin" existing.is_active = True db.session.commit() click.echo(f"Promoted user '{admin_username}' to admin") - + click.echo("Database initialization complete!") @app.cli.command() @@ -52,12 +53,12 @@ def create_admin(): if not username: click.echo("Username cannot be empty") return - + if User.query.filter_by(username=username).first(): click.echo(f"User {username} already exists") return - - user = User(username=username, role='admin') + + user = User(username=username, role="admin") db.session.add(user) db.session.commit() click.echo(f"Created admin user: {username}") @@ -67,31 +68,33 @@ def create_admin(): def backup_db(): """Create a backup of the database""" from app.config import Config - + url = Config.SQLALCHEMY_DATABASE_URI - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - - if url.startswith('sqlite:///'): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + if url.startswith("sqlite:///"): # SQLite file copy - db_path = url.replace('sqlite:///', '') + db_path = url.replace("sqlite:///", "") if not os.path.exists(db_path): click.echo(f"Database file not found: {db_path}") return - backup_dir = os.path.join(os.path.dirname(db_path), 'backups') + backup_dir = os.path.join(os.path.dirname(db_path), "backups") os.makedirs(backup_dir, exist_ok=True) backup_filename = f"timetracker_backup_{timestamp}.db" backup_path = os.path.join(backup_dir, backup_filename) shutil.copy2(db_path, backup_path) click.echo(f"Database backed up to: {backup_path}") else: - click.echo("For PostgreSQL, please use pg_dump, e.g.: pg_dump --format=custom --dbname=\"$DATABASE_URL\" --file=backup.dump") - + click.echo( + 'For PostgreSQL, please use pg_dump, e.g.: pg_dump --format=custom --dbname="$DATABASE_URL" --file=backup.dump' + ) + # Clean up old backups - if url.startswith('sqlite:///'): + if url.startswith("sqlite:///"): try: - backup_retention_days = int(os.getenv('BACKUP_RETENTION_DAYS', 30)) + backup_retention_days = int(os.getenv("BACKUP_RETENTION_DAYS", 30)) cutoff_date = datetime.now() - timedelta(days=backup_retention_days) - + for backup_file in os.listdir(backup_dir): backup_file_path = os.path.join(backup_dir, backup_file) if os.path.isfile(backup_file_path): @@ -117,11 +120,11 @@ def backup_create(): @app.cli.command() @with_appcontext - @click.argument('archive_path') + @click.argument("archive_path") def backup_restore(archive_path): """Restore from a backup archive and run migrations.""" if not archive_path: - click.echo('Usage: flask backup_restore ') + click.echo("Usage: flask backup_restore ") return try: success, message = restore_backup(click.get_current_context().obj or app, archive_path) @@ -152,6 +155,7 @@ def db_status(): """Show database migration status""" try: from flask_migrate import current + current() except Exception as e: click.echo(f"Error getting migration status: {e}") @@ -163,6 +167,7 @@ def db_history(): """Show database migration history""" try: from flask_migrate import history + history() except Exception as e: click.echo(f"Error getting migration history: {e}") @@ -170,16 +175,17 @@ def db_history(): @app.cli.command() @with_appcontext - @click.option('--days', default=7, help='Generate entries for the next N days') + @click.option("--days", default=7, help="Generate entries for the next N days") def generate_recurring(days): """Expand active recurring time blocks into concrete time entries for the next N days.""" from datetime import date, time from app.utils.timezone import get_timezone_obj + tz = get_timezone_obj() today = datetime.now(tz).date() end = today + timedelta(days=int(days)) - weekday_map = { 'mon':0, 'tue':1, 'wed':2, 'thu':3, 'fri':4, 'sat':5, 'sun':6 } + weekday_map = {"mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6} blocks = RecurringBlock.query.filter_by(is_active=True).all() created = 0 @@ -190,14 +196,14 @@ def generate_recurring(days): window_end = min(end, stop_date) if window_end < window_start: continue - weekdays = [(w.strip().lower()) for w in (b.weekdays or '').split(',') if w.strip()] - weekday_nums = { weekday_map[w] for w in weekdays if w in weekday_map } + weekdays = [(w.strip().lower()) for w in (b.weekdays or "").split(",") if w.strip()] + weekday_nums = {weekday_map[w] for w in weekdays if w in weekday_map} cur = window_start while cur <= window_end: if not weekday_nums or cur.weekday() in weekday_nums: try: - sh, sm = [int(x) for x in b.start_time_local.split(':')] - eh, em = [int(x) for x in b.end_time_local.split(':')] + sh, sm = [int(x) for x in b.start_time_local.split(":")] + eh, em = [int(x) for x in b.end_time_local.split(":")] except Exception: cur += timedelta(days=1) continue @@ -209,8 +215,7 @@ def generate_recurring(days): continue # Avoid duplicates: skip if overlapping entry exists for same user/project in this window exists = ( - TimeEntry.query - .filter(TimeEntry.user_id == b.user_id, TimeEntry.project_id == b.project_id) + TimeEntry.query.filter(TimeEntry.user_id == b.user_id, TimeEntry.project_id == b.project_id) .filter(TimeEntry.start_time == start_dt, TimeEntry.end_time == end_dt) .first() ) @@ -225,7 +230,7 @@ def generate_recurring(days): end_time=end_dt, notes=b.notes, tags=b.tags, - source='manual', + source="manual", billable=b.billable, ) db.session.add(te) @@ -238,7 +243,7 @@ def generate_recurring(days): @with_appcontext def seed_permissions_cmd(): """Seed default permissions, roles, and migrate existing users - + Note: This is now optional! The database migration (flask db upgrade) automatically seeds permissions and roles. This command is only needed if you want to re-seed or update permissions after the initial migration. @@ -253,7 +258,7 @@ def seed_permissions_cmd(): @with_appcontext def update_permissions(): """Update permissions and roles after system updates - + Use this command to add new permissions or update role definitions without affecting existing user role assignments. """ diff --git a/app/utils/config_manager.py b/app/utils/config_manager.py index e4caa27f..d5042eea 100644 --- a/app/utils/config_manager.py +++ b/app/utils/config_manager.py @@ -10,22 +10,22 @@ class ConfigManager: """Utility for managing application configuration""" - + @staticmethod def get_setting(key: str, default: Any = None) -> Any: """ Get a setting value. - + Checks in order: 1. Settings model (WebUI changes have highest priority) 2. Environment variable (.env file - used as initial values) 3. App config 4. Default value - + Args: key: Setting key default: Default value if not found - + Returns: Setting value """ @@ -38,29 +38,29 @@ def get_setting(key: str, default: Any = None) -> Any: return value except Exception: pass - + # Check environment variable second (.env file - used as initial values) env_value = os.getenv(key.upper()) if env_value is not None: return env_value - + # Check app config if current_app: value = current_app.config.get(key, default) if value is not None: return value - + return default - + @staticmethod def set_setting(key: str, value: Any) -> bool: """ Set a setting value in the Settings model. - + Args: key: Setting key value: Setting value - + Returns: True if successful """ @@ -69,44 +69,40 @@ def set_setting(key: str, value: Any) -> bool: if settings and hasattr(settings, key): setattr(settings, key, value) from app import db + db.session.commit() return True except Exception: pass - + return False - + @staticmethod def validate_config() -> Dict[str, Any]: """ Validate application configuration. - + Returns: dict with validation results """ errors = [] warnings = [] - + # Check required settings - required_settings = ['SECRET_KEY', 'SQLALCHEMY_DATABASE_URI'] + required_settings = ["SECRET_KEY", "SQLALCHEMY_DATABASE_URI"] for setting in required_settings: value = ConfigManager.get_setting(setting) if not value: errors.append(f"Missing required setting: {setting}") - + # Check secret key strength - secret_key = ConfigManager.get_setting('SECRET_KEY') + secret_key = ConfigManager.get_setting("SECRET_KEY") if secret_key and len(secret_key) < 32: warnings.append("SECRET_KEY is too short (should be at least 32 characters)") - + # Check database URL - db_url = ConfigManager.get_setting('SQLALCHEMY_DATABASE_URI') - if db_url and 'dev-secret-key' in str(db_url): + db_url = ConfigManager.get_setting("SQLALCHEMY_DATABASE_URI") + if db_url and "dev-secret-key" in str(db_url): warnings.append("Using default database configuration") - - return { - 'valid': len(errors) == 0, - 'errors': errors, - 'warnings': warnings - } + return {"valid": len(errors) == 0, "errors": errors, "warnings": warnings} diff --git a/app/utils/context_processors.py b/app/utils/context_processors.py index f25f9708..fc67f284 100644 --- a/app/utils/context_processors.py +++ b/app/utils/context_processors.py @@ -4,70 +4,72 @@ from app.models import Settings from app.utils.timezone import get_timezone_offset_for_timezone + def register_context_processors(app): """Register context processors for the application""" - + # Register permission helpers for templates from app.utils.permissions import init_permission_helpers + init_permission_helpers(app) - + @app.context_processor def inject_settings(): """Inject settings into all templates""" try: from app import db + # Check if we have an active database session if db.session.is_active: settings = Settings.get_settings() - return { - 'settings': settings, - 'currency': settings.currency, - 'timezone': settings.timezone - } + return {"settings": settings, "currency": settings.currency, "timezone": settings.timezone} except Exception as e: # Log the error but continue with defaults print(f"Warning: Could not inject settings: {e}") # Rollback the failed transaction try: from app import db + db.session.rollback() except Exception: pass pass - + # Return defaults if settings not available - return { - 'settings': None, - 'currency': 'EUR', - 'timezone': 'Europe/Rome' - } - + return {"settings": None, "currency": "EUR", "timezone": "Europe/Rome"} + @app.context_processor def inject_globals(): """Inject global variables into all templates""" try: from app import db + # Check if we have an active database session if db.session.is_active: settings = Settings.get_settings() - timezone_name = settings.timezone if settings else 'Europe/Rome' + timezone_name = settings.timezone if settings else "Europe/Rome" else: - timezone_name = 'Europe/Rome' + timezone_name = "Europe/Rome" except Exception as e: # Log the error but continue with defaults print(f"Warning: Could not inject globals: {e}") # Rollback the failed transaction try: from app import db + db.session.rollback() except Exception: pass - timezone_name = 'Europe/Rome' + timezone_name = "Europe/Rome" # Resolve user-specific timezone, falling back to application timezone user_timezone = timezone_name try: - if current_user and getattr(current_user, 'is_authenticated', False) and getattr(current_user, 'timezone', None): + if ( + current_user + and getattr(current_user, "is_authenticated", False) + and getattr(current_user, "timezone", None) + ): user_timezone = current_user.timezone except Exception: pass @@ -76,51 +78,52 @@ def inject_globals(): try: import os from app.config import Config - env_version = os.getenv('APP_VERSION') + + env_version = os.getenv("APP_VERSION") # If running in GitHub Actions build, prefer tag-like versions - version_value = env_version or getattr(Config, 'APP_VERSION', None) or 'dev-0' + version_value = env_version or getattr(Config, "APP_VERSION", None) or "dev-0" # Strip any leading 'v' prefix to avoid double 'v' in template (e.g., vv3.5.0) - if version_value and version_value.startswith('v'): + if version_value and version_value.startswith("v"): version_value = version_value[1:] except Exception: - version_value = 'dev-0' - + version_value = "dev-0" + # Current locale code (e.g., 'en', 'de') try: current_locale = str(get_locale()) except Exception: - current_locale = 'en' + current_locale = "en" # Normalize to short code for comparisons (e.g., 'en' from 'en_US') - short_locale = (current_locale.split('_', 1)[0] if current_locale else 'en') - + short_locale = current_locale.split("_", 1)[0] if current_locale else "en" + # Reverse-map normalized locale codes back to config keys for label lookup # 'nb' (used by Flask-Babel) should map back to 'no' (used in LANGUAGES config) display_locale = short_locale - if short_locale == 'nb': - display_locale = 'no' - - available_languages = current_app.config.get('LANGUAGES', {}) or {} + if short_locale == "nb": + display_locale = "no" + + available_languages = current_app.config.get("LANGUAGES", {}) or {} current_language_label = available_languages.get(display_locale, short_locale.upper()) - + # Check if current language is RTL - rtl_languages = current_app.config.get('RTL_LANGUAGES', set()) + rtl_languages = current_app.config.get("RTL_LANGUAGES", set()) is_rtl = short_locale in rtl_languages return { - 'app_name': 'Time Tracker', - 'app_version': version_value, - 'timezone': timezone_name, - 'timezone_offset': get_timezone_offset_for_timezone(timezone_name), - 'user_timezone': user_timezone, - 'current_locale': current_locale, - 'current_language_code': display_locale, # Use display locale (e.g., 'no' not 'nb') - 'current_language_label': current_language_label, - 'is_rtl': is_rtl, - 'available_languages': available_languages, - 'config': current_app.config + "app_name": "Time Tracker", + "app_version": version_value, + "timezone": timezone_name, + "timezone_offset": get_timezone_offset_for_timezone(timezone_name), + "user_timezone": user_timezone, + "current_locale": current_locale, + "current_language_code": display_locale, # Use display locale (e.g., 'no' not 'nb') + "current_language_label": current_language_label, + "is_rtl": is_rtl, + "available_languages": available_languages, + "config": current_app.config, } - + @app.before_request def before_request(): """Set up request-specific data""" - g.request_start_time = request.start_time if hasattr(request, 'start_time') else None + g.request_start_time = request.start_time if hasattr(request, "start_time") else None diff --git a/app/utils/data_export.py b/app/utils/data_export.py index 27306dd3..2f2beecc 100644 --- a/app/utils/data_export.py +++ b/app/utils/data_export.py @@ -1,6 +1,7 @@ """ Data export utilities for GDPR compliance and general export functionality """ + import json import csv import os @@ -10,263 +11,277 @@ from flask import current_app from app import db from app.models import ( - User, Project, TimeEntry, Task, Client, Invoice, InvoiceItem, - Expense, ExpenseCategory, Mileage, PerDiem, Comment, FocusSession, - RecurringBlock, Payment, CreditNote, SavedFilter, ProjectCost, - WeeklyTimeGoal, Activity, CalendarEvent, BudgetAlert + User, + Project, + TimeEntry, + Task, + Client, + Invoice, + InvoiceItem, + Expense, + ExpenseCategory, + Mileage, + PerDiem, + Comment, + FocusSession, + RecurringBlock, + Payment, + CreditNote, + SavedFilter, + ProjectCost, + WeeklyTimeGoal, + Activity, + CalendarEvent, + BudgetAlert, ) -def export_user_data_gdpr(user_id, export_format='json'): +def export_user_data_gdpr(user_id, export_format="json"): """ Export all user data for GDPR compliance - + Args: user_id: ID of the user whose data to export export_format: Format to export ('json', 'csv', 'zip') - + Returns: Dictionary with file path and metadata """ user = User.query.get(user_id) if not user: raise ValueError(f"User {user_id} not found") - + # Collect all user data data = { - 'export_info': { - 'user_id': user_id, - 'username': user.username, - 'export_date': datetime.utcnow().isoformat(), - 'export_type': 'GDPR Full Data Export', + "export_info": { + "user_id": user_id, + "username": user.username, + "export_date": datetime.utcnow().isoformat(), + "export_type": "GDPR Full Data Export", }, - 'user_profile': _export_user_profile(user), - 'time_entries': _export_time_entries(user), - 'projects': _export_user_projects(user), - 'tasks': _export_user_tasks(user), - 'expenses': _export_user_expenses(user), - 'mileage': _export_user_mileage(user), - 'per_diems': _export_user_per_diems(user), - 'invoices': _export_user_invoices(user), - 'comments': _export_user_comments(user), - 'focus_sessions': _export_user_focus_sessions(user), - 'saved_filters': _export_user_saved_filters(user), - 'project_costs': _export_user_project_costs(user), - 'weekly_goals': _export_user_weekly_goals(user), - 'activities': _export_user_activities(user), - 'calendar_events': _export_user_calendar_events(user), + "user_profile": _export_user_profile(user), + "time_entries": _export_time_entries(user), + "projects": _export_user_projects(user), + "tasks": _export_user_tasks(user), + "expenses": _export_user_expenses(user), + "mileage": _export_user_mileage(user), + "per_diems": _export_user_per_diems(user), + "invoices": _export_user_invoices(user), + "comments": _export_user_comments(user), + "focus_sessions": _export_user_focus_sessions(user), + "saved_filters": _export_user_saved_filters(user), + "project_costs": _export_user_project_costs(user), + "weekly_goals": _export_user_weekly_goals(user), + "activities": _export_user_activities(user), + "calendar_events": _export_user_calendar_events(user), } - + # Generate export file - export_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'exports') + export_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "exports") os.makedirs(export_dir, exist_ok=True) - - timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") filename = f"gdpr_export_{user.username}_{timestamp}" - - if export_format == 'json': + + if export_format == "json": filepath = os.path.join(export_dir, f"{filename}.json") - with open(filepath, 'w', encoding='utf-8') as f: + with open(filepath, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False, default=str) file_size = os.path.getsize(filepath) - - elif export_format == 'zip': + + elif export_format == "zip": # Create ZIP with separate CSV files for each data type filepath = os.path.join(export_dir, f"{filename}.zip") - with ZipFile(filepath, 'w') as zipf: + with ZipFile(filepath, "w") as zipf: # Add JSON version zipf.writestr(f"{filename}.json", json.dumps(data, indent=2, ensure_ascii=False, default=str)) - + # Add CSV files for each data type for key, value in data.items(): - if key != 'export_info' and isinstance(value, list) and len(value) > 0: + if key != "export_info" and isinstance(value, list) and len(value) > 0: csv_content = _list_to_csv(value) zipf.writestr(f"{key}.csv", csv_content) - + file_size = os.path.getsize(filepath) - + else: raise ValueError(f"Unsupported export format: {export_format}") - + record_count = sum(len(v) if isinstance(v, list) else 1 for v in data.values()) - + return { - 'filepath': filepath, - 'file_size': file_size, - 'record_count': record_count, - 'filename': os.path.basename(filepath) + "filepath": filepath, + "file_size": file_size, + "record_count": record_count, + "filename": os.path.basename(filepath), } -def export_filtered_data(user_id, filters, export_format='json'): +def export_filtered_data(user_id, filters, export_format="json"): """ Export filtered data based on user criteria - + Args: user_id: ID of the user requesting export filters: Dictionary with filter criteria export_format: Format to export ('json', 'csv', 'xlsx') - + Returns: Dictionary with file path and metadata """ user = User.query.get(user_id) if not user: raise ValueError(f"User {user_id} not found") - + data = {} - + # Export time entries with filters - if filters.get('include_time_entries', True): + if filters.get("include_time_entries", True): query = TimeEntry.query - + if not user.is_admin: query = query.filter_by(user_id=user_id) - - if filters.get('start_date'): - start_date = datetime.fromisoformat(filters['start_date']) + + if filters.get("start_date"): + start_date = datetime.fromisoformat(filters["start_date"]) query = query.filter(TimeEntry.start_time >= start_date) - - if filters.get('end_date'): - end_date = datetime.fromisoformat(filters['end_date']) + + if filters.get("end_date"): + end_date = datetime.fromisoformat(filters["end_date"]) query = query.filter(TimeEntry.start_time <= end_date) - - if filters.get('project_id'): - query = query.filter_by(project_id=filters['project_id']) - - if filters.get('billable_only'): + + if filters.get("project_id"): + query = query.filter_by(project_id=filters["project_id"]) + + if filters.get("billable_only"): query = query.filter_by(billable=True) - + time_entries = query.all() - data['time_entries'] = [_time_entry_to_dict(te) for te in time_entries] - + data["time_entries"] = [_time_entry_to_dict(te) for te in time_entries] + # Export other data types based on filters - if filters.get('include_projects'): + if filters.get("include_projects"): projects = Project.query.all() if user.is_admin else [] - data['projects'] = [_project_to_dict(p) for p in projects] - - if filters.get('include_expenses'): + data["projects"] = [_project_to_dict(p) for p in projects] + + if filters.get("include_expenses"): query = Expense.query if not user.is_admin: query = query.filter_by(user_id=user_id) expenses = query.all() - data['expenses'] = [_expense_to_dict(e) for e in expenses] - + data["expenses"] = [_expense_to_dict(e) for e in expenses] + # Generate export file - export_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'exports') + export_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "exports") os.makedirs(export_dir, exist_ok=True) - - timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") filename = f"filtered_export_{user.username}_{timestamp}" - - if export_format == 'json': + + if export_format == "json": filepath = os.path.join(export_dir, f"{filename}.json") - with open(filepath, 'w', encoding='utf-8') as f: + with open(filepath, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False, default=str) file_size = os.path.getsize(filepath) - - elif export_format == 'csv': + + elif export_format == "csv": # Export as single CSV (time entries) filepath = os.path.join(export_dir, f"{filename}.csv") - if 'time_entries' in data: - csv_content = _list_to_csv(data['time_entries']) - with open(filepath, 'w', encoding='utf-8') as f: + if "time_entries" in data: + csv_content = _list_to_csv(data["time_entries"]) + with open(filepath, "w", encoding="utf-8") as f: f.write(csv_content) file_size = os.path.getsize(filepath) - + else: raise ValueError(f"Unsupported export format: {export_format}") - + record_count = sum(len(v) if isinstance(v, list) else 1 for v in data.values()) - + return { - 'filepath': filepath, - 'file_size': file_size, - 'record_count': record_count, - 'filename': os.path.basename(filepath) + "filepath": filepath, + "file_size": file_size, + "record_count": record_count, + "filename": os.path.basename(filepath), } def create_backup(user_id): """ Create a complete database backup for restore functionality - + Args: user_id: ID of the admin user creating the backup - + Returns: Dictionary with backup file path and metadata """ user = User.query.get(user_id) if not user or not user.is_admin: raise ValueError("Only admin users can create backups") - + # Export all data from all tables backup_data = { - 'backup_info': { - 'created_by': user.username, - 'created_at': datetime.utcnow().isoformat(), - 'version': '1.0', + "backup_info": { + "created_by": user.username, + "created_at": datetime.utcnow().isoformat(), + "version": "1.0", }, - 'users': [u.to_dict() for u in User.query.all()], - 'clients': [_client_to_dict(c) for c in Client.query.all()], - 'projects': [_project_to_dict(p) for p in Project.query.all()], - 'tasks': [_task_to_dict(t) for t in Task.query.all()], - 'time_entries': [_time_entry_to_dict(te) for te in TimeEntry.query.all()], - 'expenses': [_expense_to_dict(e) for e in Expense.query.all()], - 'expense_categories': [_expense_category_to_dict(ec) for ec in ExpenseCategory.query.all()], - 'mileage': [_mileage_to_dict(m) for m in Mileage.query.all()], - 'per_diems': [_per_diem_to_dict(pd) for pd in PerDiem.query.all()], - 'invoices': [_invoice_to_dict(i) for i in Invoice.query.all()], - 'comments': [_comment_to_dict(c) for c in Comment.query.all()], - 'focus_sessions': [_focus_session_to_dict(fs) for fs in FocusSession.query.all()], - 'recurring_blocks': [_recurring_block_to_dict(rb) for rb in RecurringBlock.query.all()], - 'saved_filters': [_saved_filter_to_dict(sf) for sf in SavedFilter.query.all()], - 'project_costs': [_project_cost_to_dict(pc) for pc in ProjectCost.query.all()], - 'weekly_goals': [_weekly_goal_to_dict(wg) for wg in WeeklyTimeGoal.query.all()], - 'calendar_events': [_calendar_event_to_dict(ce) for ce in CalendarEvent.query.all()], + "users": [u.to_dict() for u in User.query.all()], + "clients": [_client_to_dict(c) for c in Client.query.all()], + "projects": [_project_to_dict(p) for p in Project.query.all()], + "tasks": [_task_to_dict(t) for t in Task.query.all()], + "time_entries": [_time_entry_to_dict(te) for te in TimeEntry.query.all()], + "expenses": [_expense_to_dict(e) for e in Expense.query.all()], + "expense_categories": [_expense_category_to_dict(ec) for ec in ExpenseCategory.query.all()], + "mileage": [_mileage_to_dict(m) for m in Mileage.query.all()], + "per_diems": [_per_diem_to_dict(pd) for pd in PerDiem.query.all()], + "invoices": [_invoice_to_dict(i) for i in Invoice.query.all()], + "comments": [_comment_to_dict(c) for c in Comment.query.all()], + "focus_sessions": [_focus_session_to_dict(fs) for fs in FocusSession.query.all()], + "recurring_blocks": [_recurring_block_to_dict(rb) for rb in RecurringBlock.query.all()], + "saved_filters": [_saved_filter_to_dict(sf) for sf in SavedFilter.query.all()], + "project_costs": [_project_cost_to_dict(pc) for pc in ProjectCost.query.all()], + "weekly_goals": [_weekly_goal_to_dict(wg) for wg in WeeklyTimeGoal.query.all()], + "calendar_events": [_calendar_event_to_dict(ce) for ce in CalendarEvent.query.all()], } - + # Create backup file - backup_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'backups') + backup_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "backups") os.makedirs(backup_dir, exist_ok=True) - - timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S') + + timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S") filename = f"backup_{timestamp}.json" filepath = os.path.join(backup_dir, filename) - - with open(filepath, 'w', encoding='utf-8') as f: + + with open(filepath, "w", encoding="utf-8") as f: json.dump(backup_data, f, indent=2, ensure_ascii=False, default=str) - + file_size = os.path.getsize(filepath) record_count = sum(len(v) if isinstance(v, list) else 1 for v in backup_data.values()) - - return { - 'filepath': filepath, - 'file_size': file_size, - 'record_count': record_count, - 'filename': filename - } + + return {"filepath": filepath, "file_size": file_size, "record_count": record_count, "filename": filename} # Helper functions to convert models to dictionaries + def _export_user_profile(user): """Export user profile data""" return { - 'id': user.id, - 'username': user.username, - 'email': user.email, - 'full_name': user.full_name, - 'role': user.role, - 'created_at': user.created_at.isoformat() if user.created_at else None, - 'last_login': user.last_login.isoformat() if user.last_login else None, - 'theme_preference': user.theme_preference, - 'preferred_language': user.preferred_language, - 'timezone': user.timezone, - 'date_format': user.date_format, - 'time_format': user.time_format, - 'week_start_day': user.week_start_day, + "id": user.id, + "username": user.username, + "email": user.email, + "full_name": user.full_name, + "role": user.role, + "created_at": user.created_at.isoformat() if user.created_at else None, + "last_login": user.last_login.isoformat() if user.last_login else None, + "theme_preference": user.theme_preference, + "preferred_language": user.preferred_language, + "timezone": user.timezone, + "date_format": user.date_format, + "time_format": user.time_format, + "week_start_day": user.week_start_day, } @@ -361,233 +376,234 @@ def _export_user_calendar_events(user): # Model to dict converters + def _time_entry_to_dict(entry): """Convert time entry to dictionary""" return { - 'id': entry.id, - 'user_id': entry.user_id, - 'user': entry.user.username if entry.user else None, - 'project_id': entry.project_id, - 'project': entry.project.name if entry.project else None, - 'task_id': entry.task_id, - 'task': entry.task.name if entry.task else None, - '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, - 'duration_hours': entry.duration_hours, - 'notes': entry.notes, - 'tags': entry.tags, - 'source': entry.source, - 'billable': entry.billable, - 'created_at': entry.created_at.isoformat() if entry.created_at else None, - 'updated_at': entry.updated_at.isoformat() if entry.updated_at else None, + "id": entry.id, + "user_id": entry.user_id, + "user": entry.user.username if entry.user else None, + "project_id": entry.project_id, + "project": entry.project.name if entry.project else None, + "task_id": entry.task_id, + "task": entry.task.name if entry.task else None, + "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, + "duration_hours": entry.duration_hours, + "notes": entry.notes, + "tags": entry.tags, + "source": entry.source, + "billable": entry.billable, + "created_at": entry.created_at.isoformat() if entry.created_at else None, + "updated_at": entry.updated_at.isoformat() if entry.updated_at else None, } def _project_to_dict(project): """Convert project to dictionary""" return { - 'id': project.id, - 'name': project.name, - 'client_id': project.client_id, - 'client': project.client, - 'description': project.description, - 'billable': project.billable, - 'hourly_rate': float(project.hourly_rate) if project.hourly_rate else None, - 'billing_ref': project.billing_ref, - 'code': project.code, - 'status': project.status, - 'estimated_hours': project.estimated_hours, - 'budget_amount': float(project.budget_amount) if project.budget_amount else None, - 'created_at': project.created_at.isoformat() if project.created_at else None, + "id": project.id, + "name": project.name, + "client_id": project.client_id, + "client": project.client, + "description": project.description, + "billable": project.billable, + "hourly_rate": float(project.hourly_rate) if project.hourly_rate else None, + "billing_ref": project.billing_ref, + "code": project.code, + "status": project.status, + "estimated_hours": project.estimated_hours, + "budget_amount": float(project.budget_amount) if project.budget_amount else None, + "created_at": project.created_at.isoformat() if project.created_at else None, } def _client_to_dict(client): """Convert client to dictionary""" return { - 'id': client.id, - 'name': client.name, - 'email': client.email, - 'phone': client.phone, - 'address': client.address, - 'created_at': client.created_at.isoformat() if client.created_at else None, + "id": client.id, + "name": client.name, + "email": client.email, + "phone": client.phone, + "address": client.address, + "created_at": client.created_at.isoformat() if client.created_at else None, } def _task_to_dict(task): """Convert task to dictionary""" return { - 'id': task.id, - 'name': task.name, - 'description': task.description, - 'project_id': task.project_id, - 'project': task.project.name if task.project else None, - 'assigned_to': task.assigned_to, - 'status': task.status, - 'priority': task.priority, - 'due_date': task.due_date.isoformat() if task.due_date else None, - 'created_at': task.created_at.isoformat() if task.created_at else None, + "id": task.id, + "name": task.name, + "description": task.description, + "project_id": task.project_id, + "project": task.project.name if task.project else None, + "assigned_to": task.assigned_to, + "status": task.status, + "priority": task.priority, + "due_date": task.due_date.isoformat() if task.due_date else None, + "created_at": task.created_at.isoformat() if task.created_at else None, } def _expense_to_dict(expense): """Convert expense to dictionary""" return { - 'id': expense.id, - 'user_id': expense.user_id, - 'project_id': expense.project_id, - 'category_id': expense.category_id, - 'amount': float(expense.amount) if expense.amount else None, - 'currency': expense.currency, - 'description': expense.description, - 'date': expense.date.isoformat() if expense.date else None, - 'billable': expense.billable, - 'created_at': expense.created_at.isoformat() if expense.created_at else None, + "id": expense.id, + "user_id": expense.user_id, + "project_id": expense.project_id, + "category_id": expense.category_id, + "amount": float(expense.amount) if expense.amount else None, + "currency": expense.currency, + "description": expense.description, + "date": expense.date.isoformat() if expense.date else None, + "billable": expense.billable, + "created_at": expense.created_at.isoformat() if expense.created_at else None, } def _expense_category_to_dict(category): """Convert expense category to dictionary""" return { - 'id': category.id, - 'name': category.name, - 'description': category.description, + "id": category.id, + "name": category.name, + "description": category.description, } def _mileage_to_dict(mileage): """Convert mileage to dictionary""" return { - 'id': mileage.id, - 'user_id': mileage.user_id, - 'project_id': mileage.project_id, - 'distance': float(mileage.distance) if mileage.distance else None, - 'unit': mileage.unit, - 'purpose': mileage.purpose, - 'date': mileage.date.isoformat() if mileage.date else None, - 'created_at': mileage.created_at.isoformat() if mileage.created_at else None, + "id": mileage.id, + "user_id": mileage.user_id, + "project_id": mileage.project_id, + "distance": float(mileage.distance) if mileage.distance else None, + "unit": mileage.unit, + "purpose": mileage.purpose, + "date": mileage.date.isoformat() if mileage.date else None, + "created_at": mileage.created_at.isoformat() if mileage.created_at else None, } def _per_diem_to_dict(per_diem): """Convert per diem to dictionary""" return { - 'id': per_diem.id, - 'user_id': per_diem.user_id, - 'project_id': per_diem.project_id, - 'date': per_diem.date.isoformat() if per_diem.date else None, - 'amount': float(per_diem.amount) if per_diem.amount else None, - 'description': per_diem.description, - 'created_at': per_diem.created_at.isoformat() if per_diem.created_at else None, + "id": per_diem.id, + "user_id": per_diem.user_id, + "project_id": per_diem.project_id, + "date": per_diem.date.isoformat() if per_diem.date else None, + "amount": float(per_diem.amount) if per_diem.amount else None, + "description": per_diem.description, + "created_at": per_diem.created_at.isoformat() if per_diem.created_at else None, } def _invoice_to_dict(invoice): """Convert invoice to dictionary""" return { - 'id': invoice.id, - 'invoice_number': invoice.invoice_number, - 'client_id': invoice.client_id, - 'project_id': invoice.project_id, - 'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None, - 'due_date': invoice.due_date.isoformat() if invoice.due_date else None, - 'total_amount': float(invoice.total_amount) if invoice.total_amount else None, - 'status': invoice.status, - 'created_at': invoice.created_at.isoformat() if invoice.created_at else None, + "id": invoice.id, + "invoice_number": invoice.invoice_number, + "client_id": invoice.client_id, + "project_id": invoice.project_id, + "issue_date": invoice.issue_date.isoformat() if invoice.issue_date else None, + "due_date": invoice.due_date.isoformat() if invoice.due_date else None, + "total_amount": float(invoice.total_amount) if invoice.total_amount else None, + "status": invoice.status, + "created_at": invoice.created_at.isoformat() if invoice.created_at else None, } def _comment_to_dict(comment): """Convert comment to dictionary""" return { - 'id': comment.id, - 'user_id': comment.user_id, - 'content': comment.content, - 'created_at': comment.created_at.isoformat() if comment.created_at else None, + "id": comment.id, + "user_id": comment.user_id, + "content": comment.content, + "created_at": comment.created_at.isoformat() if comment.created_at else None, } def _focus_session_to_dict(session): """Convert focus session to dictionary""" return { - 'id': session.id, - 'user_id': session.user_id, - 'start_time': session.start_time.isoformat() if session.start_time else None, - 'end_time': session.end_time.isoformat() if session.end_time else None, - 'duration_minutes': session.duration_minutes, - 'created_at': session.created_at.isoformat() if session.created_at else None, + "id": session.id, + "user_id": session.user_id, + "start_time": session.start_time.isoformat() if session.start_time else None, + "end_time": session.end_time.isoformat() if session.end_time else None, + "duration_minutes": session.duration_minutes, + "created_at": session.created_at.isoformat() if session.created_at else None, } def _recurring_block_to_dict(block): """Convert recurring block to dictionary""" return { - 'id': block.id, - 'user_id': block.user_id, - 'title': block.title, - 'description': block.description, - 'created_at': block.created_at.isoformat() if block.created_at else None, + "id": block.id, + "user_id": block.user_id, + "title": block.title, + "description": block.description, + "created_at": block.created_at.isoformat() if block.created_at else None, } def _saved_filter_to_dict(filter_obj): """Convert saved filter to dictionary""" return { - 'id': filter_obj.id, - 'user_id': filter_obj.user_id, - 'name': filter_obj.name, - 'filter_data': filter_obj.filter_data, - 'created_at': filter_obj.created_at.isoformat() if filter_obj.created_at else None, + "id": filter_obj.id, + "user_id": filter_obj.user_id, + "name": filter_obj.name, + "filter_data": filter_obj.filter_data, + "created_at": filter_obj.created_at.isoformat() if filter_obj.created_at else None, } def _project_cost_to_dict(cost): """Convert project cost to dictionary""" return { - 'id': cost.id, - 'project_id': cost.project_id, - 'user_id': cost.user_id, - 'amount': float(cost.amount) if cost.amount else None, - 'description': cost.description, - 'date': cost.date.isoformat() if cost.date else None, - 'billable': cost.billable, - 'created_at': cost.created_at.isoformat() if cost.created_at else None, + "id": cost.id, + "project_id": cost.project_id, + "user_id": cost.user_id, + "amount": float(cost.amount) if cost.amount else None, + "description": cost.description, + "date": cost.date.isoformat() if cost.date else None, + "billable": cost.billable, + "created_at": cost.created_at.isoformat() if cost.created_at else None, } def _weekly_goal_to_dict(goal): """Convert weekly goal to dictionary""" return { - 'id': goal.id, - 'user_id': goal.user_id, - 'week_start': goal.week_start.isoformat() if goal.week_start else None, - 'target_hours': float(goal.target_hours) if goal.target_hours else None, - 'created_at': goal.created_at.isoformat() if goal.created_at else None, + "id": goal.id, + "user_id": goal.user_id, + "week_start": goal.week_start.isoformat() if goal.week_start else None, + "target_hours": float(goal.target_hours) if goal.target_hours else None, + "created_at": goal.created_at.isoformat() if goal.created_at else None, } def _activity_to_dict(activity): """Convert activity to dictionary""" return { - 'id': activity.id, - 'user_id': activity.user_id, - 'action': activity.action, - 'details': activity.details, - 'created_at': activity.created_at.isoformat() if activity.created_at else None, + "id": activity.id, + "user_id": activity.user_id, + "action": activity.action, + "details": activity.details, + "created_at": activity.created_at.isoformat() if activity.created_at else None, } def _calendar_event_to_dict(event): """Convert calendar event to dictionary""" return { - 'id': event.id, - 'user_id': event.user_id, - 'title': event.title, - 'description': event.description, - 'start_time': event.start_time.isoformat() if event.start_time else None, - 'end_time': event.end_time.isoformat() if event.end_time else None, - 'created_at': event.created_at.isoformat() if event.created_at else None, + "id": event.id, + "user_id": event.user_id, + "title": event.title, + "description": event.description, + "start_time": event.start_time.isoformat() if event.start_time else None, + "end_time": event.end_time.isoformat() if event.end_time else None, + "created_at": event.created_at.isoformat() if event.created_at else None, } @@ -595,13 +611,12 @@ def _list_to_csv(data_list): """Convert list of dictionaries to CSV string""" if not data_list: return "" - + output = StringIO() if len(data_list) > 0: fieldnames = data_list[0].keys() writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() writer.writerows(data_list) - - return output.getvalue() + return output.getvalue() diff --git a/app/utils/data_import.py b/app/utils/data_import.py index 605a4c9d..356bb3f6 100644 --- a/app/utils/data_import.py +++ b/app/utils/data_import.py @@ -1,6 +1,7 @@ """ Data import utilities for importing time tracking data from various sources """ + import json import csv import requests @@ -14,30 +15,31 @@ class ImportError(Exception): """Custom exception for import errors""" + pass def import_csv_time_entries(user_id, csv_content, import_record): """ Import time entries from CSV file - + Expected CSV format: project_name, task_name, start_time, end_time, duration_hours, notes, tags, billable - + Args: user_id: ID of the user importing data csv_content: String content of CSV file import_record: DataImport model instance to track progress - + Returns: Dictionary with import statistics """ user = User.query.get(user_id) if not user: raise ImportError(f"User {user_id} not found") - + import_record.start_processing() - + # Parse CSV try: csv_reader = csv.DictReader(StringIO(csv_content)) @@ -45,61 +47,55 @@ def import_csv_time_entries(user_id, csv_content, import_record): except Exception as e: import_record.fail(f"Failed to parse CSV: {str(e)}") raise ImportError(f"Failed to parse CSV: {str(e)}") - + total = len(rows) successful = 0 failed = 0 errors = [] - + import_record.update_progress(total, 0, 0) - + for idx, row in enumerate(rows): try: # Get or create project - project_name = row.get('project_name', '').strip() + project_name = row.get("project_name", "").strip() if not project_name: raise ValueError("Project name is required") - + # Get or create client - client_name = row.get('client_name', project_name).strip() + client_name = row.get("client_name", project_name).strip() client = Client.query.filter_by(name=client_name).first() if not client: client = Client(name=client_name) db.session.add(client) db.session.flush() - + # Get or create project project = Project.query.filter_by(name=project_name, client_id=client.id).first() if not project: project = Project( - name=project_name, - client_id=client.id, - billable=row.get('billable', 'true').lower() == 'true' + name=project_name, client_id=client.id, billable=row.get("billable", "true").lower() == "true" ) db.session.add(project) db.session.flush() - + # Get or create task (if provided) task = None - task_name = row.get('task_name', '').strip() + task_name = row.get("task_name", "").strip() if task_name: task = Task.query.filter_by(name=task_name, project_id=project.id).first() if not task: - task = Task( - name=task_name, - project_id=project.id, - status='in_progress' - ) + task = Task(name=task_name, project_id=project.id, status="in_progress") db.session.add(task) db.session.flush() - + # Parse times - start_time = _parse_datetime(row.get('start_time', row.get('start', ''))) - end_time = _parse_datetime(row.get('end_time', row.get('end', ''))) - + start_time = _parse_datetime(row.get("start_time", row.get("start", ""))) + end_time = _parse_datetime(row.get("end_time", row.get("end", ""))) + if not start_time: raise ValueError("Start time is required") - + # Create time entry time_entry = TimeEntry( user_id=user_id, @@ -107,36 +103,36 @@ def import_csv_time_entries(user_id, csv_content, import_record): task_id=task.id if task else None, start_time=start_time, end_time=end_time, - notes=row.get('notes', row.get('description', '')).strip(), - tags=row.get('tags', '').strip(), - billable=row.get('billable', 'true').lower() == 'true', - source='import' + notes=row.get("notes", row.get("description", "")).strip(), + tags=row.get("tags", "").strip(), + billable=row.get("billable", "true").lower() == "true", + source="import", ) - + # Handle duration if end_time: time_entry.calculate_duration() - elif 'duration_hours' in row: - duration_hours = float(row['duration_hours']) + elif "duration_hours" in row: + duration_hours = float(row["duration_hours"]) time_entry.duration_seconds = int(duration_hours * 3600) if not end_time and start_time: time_entry.end_time = start_time + timedelta(seconds=time_entry.duration_seconds) - + db.session.add(time_entry) successful += 1 - + # Commit every 100 records if (idx + 1) % 100 == 0: db.session.commit() import_record.update_progress(total, successful, failed) - + except Exception as e: failed += 1 error_msg = f"Row {idx + 1}: {str(e)}" errors.append(error_msg) import_record.add_error(error_msg, row) db.session.rollback() - + # Final commit try: db.session.commit() @@ -144,32 +140,27 @@ def import_csv_time_entries(user_id, csv_content, import_record): db.session.rollback() import_record.fail(f"Failed to commit final changes: {str(e)}") raise ImportError(f"Failed to commit changes: {str(e)}") - + # Update import record import_record.update_progress(total, successful, failed) - + if failed == 0: import_record.complete() elif successful > 0: import_record.partial_complete() else: import_record.fail("All records failed to import") - - summary = { - 'total': total, - 'successful': successful, - 'failed': failed, - 'errors': errors[:10] # First 10 errors - } + + summary = {"total": total, "successful": successful, "failed": failed, "errors": errors[:10]} # First 10 errors import_record.set_summary(summary) - + return summary def import_from_toggl(user_id, api_token, workspace_id, start_date, end_date, import_record): """ Import time entries from Toggl Track - + Args: user_id: ID of the user importing data api_token: Toggl API token @@ -177,125 +168,115 @@ def import_from_toggl(user_id, api_token, workspace_id, start_date, end_date, im start_date: Start date for import (datetime) end_date: End date for import (datetime) import_record: DataImport model instance to track progress - + Returns: Dictionary with import statistics """ user = User.query.get(user_id) if not user: raise ImportError(f"User {user_id} not found") - + import_record.start_processing() - + # Fetch time entries from Toggl API try: # Toggl API v9 endpoint url = f"https://api.track.toggl.com/api/v9/me/time_entries" - headers = { - 'Authorization': f'Basic {api_token}', - 'Content-Type': 'application/json' - } - params = { - 'start_date': start_date.isoformat(), - 'end_date': end_date.isoformat() - } - + headers = {"Authorization": f"Basic {api_token}", "Content-Type": "application/json"} + params = {"start_date": start_date.isoformat(), "end_date": end_date.isoformat()} + response = requests.get(url, headers=headers, params=params, timeout=30) response.raise_for_status() - + time_entries = response.json() except requests.RequestException as e: import_record.fail(f"Failed to fetch data from Toggl: {str(e)}") raise ImportError(f"Failed to fetch data from Toggl: {str(e)}") - + total = len(time_entries) successful = 0 failed = 0 errors = [] - + import_record.update_progress(total, 0, 0) - + # Fetch projects from Toggl to map IDs try: projects_url = f"https://api.track.toggl.com/api/v9/workspaces/{workspace_id}/projects" projects_response = requests.get(projects_url, headers=headers, timeout=30) projects_response.raise_for_status() - toggl_projects = {p['id']: p for p in projects_response.json()} + toggl_projects = {p["id"]: p for p in projects_response.json()} except: toggl_projects = {} - + for idx, entry in enumerate(time_entries): try: # Map Toggl project to local project - toggl_project_id = entry.get('project_id') or entry.get('pid') + toggl_project_id = entry.get("project_id") or entry.get("pid") toggl_project = toggl_projects.get(toggl_project_id, {}) - project_name = toggl_project.get('name', 'Imported Project') - + project_name = toggl_project.get("name", "Imported Project") + # Get or create client - client_name = toggl_project.get('client_name', project_name) + client_name = toggl_project.get("client_name", project_name) client = Client.query.filter_by(name=client_name).first() if not client: client = Client(name=client_name) db.session.add(client) db.session.flush() - + # Get or create project project = Project.query.filter_by(name=project_name, client_id=client.id).first() if not project: - project = Project( - name=project_name, - client_id=client.id, - billable=toggl_project.get('billable', True) - ) + project = Project(name=project_name, client_id=client.id, billable=toggl_project.get("billable", True)) db.session.add(project) db.session.flush() - + # Parse times - start_time = datetime.fromisoformat(entry['start'].replace('Z', '+00:00')) - + start_time = datetime.fromisoformat(entry["start"].replace("Z", "+00:00")) + # Toggl may have duration in seconds (positive) or negative for running timers - duration_seconds = entry.get('duration', 0) + duration_seconds = entry.get("duration", 0) if duration_seconds < 0: # Running timer, skip it continue - + end_time = None - if 'stop' in entry and entry['stop']: - end_time = datetime.fromisoformat(entry['stop'].replace('Z', '+00:00')) + if "stop" in entry and entry["stop"]: + end_time = datetime.fromisoformat(entry["stop"].replace("Z", "+00:00")) elif duration_seconds > 0: end_time = start_time + timedelta(seconds=duration_seconds) - + # Create time entry time_entry = TimeEntry( user_id=user_id, project_id=project.id, start_time=start_time.replace(tzinfo=None), # Store as naive end_time=end_time.replace(tzinfo=None) if end_time else None, - notes=entry.get('description', ''), - tags=','.join(entry.get('tags', [])), - billable=entry.get('billable', True), - source='toggl', - duration_seconds=duration_seconds if duration_seconds > 0 else None + notes=entry.get("description", ""), + tags=",".join(entry.get("tags", [])), + billable=entry.get("billable", True), + source="toggl", + duration_seconds=duration_seconds if duration_seconds > 0 else None, ) - + if end_time and not time_entry.duration_seconds: time_entry.calculate_duration() - + db.session.add(time_entry) successful += 1 - + # Commit every 50 records if (idx + 1) % 50 == 0: db.session.commit() import_record.update_progress(total, successful, failed) - + except Exception as e: failed += 1 error_msg = f"Entry {idx + 1}: {str(e)}" errors.append(error_msg) import_record.add_error(error_msg, entry) db.session.rollback() - + # Final commit try: db.session.commit() @@ -303,32 +284,27 @@ def import_from_toggl(user_id, api_token, workspace_id, start_date, end_date, im db.session.rollback() import_record.fail(f"Failed to commit final changes: {str(e)}") raise ImportError(f"Failed to commit changes: {str(e)}") - + # Update import record import_record.update_progress(total, successful, failed) - + if failed == 0: import_record.complete() elif successful > 0: import_record.partial_complete() else: import_record.fail("All records failed to import") - - summary = { - 'total': total, - 'successful': successful, - 'failed': failed, - 'errors': errors[:10] - } + + summary = {"total": total, "successful": successful, "failed": failed, "errors": errors[:10]} import_record.set_summary(summary) - + return summary def import_from_harvest(user_id, account_id, api_token, start_date, end_date, import_record): """ Import time entries from Harvest - + Args: user_id: ID of the user importing data account_id: Harvest account ID @@ -336,131 +312,121 @@ def import_from_harvest(user_id, account_id, api_token, start_date, end_date, im start_date: Start date for import (datetime) end_date: End date for import (datetime) import_record: DataImport model instance to track progress - + Returns: Dictionary with import statistics """ user = User.query.get(user_id) if not user: raise ImportError(f"User {user_id} not found") - + import_record.start_processing() - + # Fetch time entries from Harvest API try: url = "https://api.harvestapp.com/v2/time_entries" headers = { - 'Authorization': f'Bearer {api_token}', - 'Harvest-Account-ID': str(account_id), - 'User-Agent': 'TimeTracker Import' - } - params = { - 'from': start_date.strftime('%Y-%m-%d'), - 'to': end_date.strftime('%Y-%m-%d'), - 'per_page': 100 + "Authorization": f"Bearer {api_token}", + "Harvest-Account-ID": str(account_id), + "User-Agent": "TimeTracker Import", } - + params = {"from": start_date.strftime("%Y-%m-%d"), "to": end_date.strftime("%Y-%m-%d"), "per_page": 100} + all_entries = [] page = 1 - + while True: - params['page'] = page + params["page"] = page response = requests.get(url, headers=headers, params=params, timeout=30) response.raise_for_status() - + data = response.json() - all_entries.extend(data.get('time_entries', [])) - + all_entries.extend(data.get("time_entries", [])) + # Check if there are more pages - if data.get('links', {}).get('next'): + if data.get("links", {}).get("next"): page += 1 else: break - + time_entries = all_entries except requests.RequestException as e: import_record.fail(f"Failed to fetch data from Harvest: {str(e)}") raise ImportError(f"Failed to fetch data from Harvest: {str(e)}") - + total = len(time_entries) successful = 0 failed = 0 errors = [] - + import_record.update_progress(total, 0, 0) - + # Fetch projects from Harvest to map IDs try: projects_url = "https://api.harvestapp.com/v2/projects" projects_response = requests.get(projects_url, headers=headers, timeout=30) projects_response.raise_for_status() - harvest_projects = {p['id']: p for p in projects_response.json().get('projects', [])} + harvest_projects = {p["id"]: p for p in projects_response.json().get("projects", [])} except: harvest_projects = {} - + # Fetch clients from Harvest try: clients_url = "https://api.harvestapp.com/v2/clients" clients_response = requests.get(clients_url, headers=headers, timeout=30) clients_response.raise_for_status() - harvest_clients = {c['id']: c for c in clients_response.json().get('clients', [])} + harvest_clients = {c["id"]: c for c in clients_response.json().get("clients", [])} except: harvest_clients = {} - + for idx, entry in enumerate(time_entries): try: # Map Harvest project to local project - harvest_project_id = entry.get('project', {}).get('id') + harvest_project_id = entry.get("project", {}).get("id") harvest_project = harvest_projects.get(harvest_project_id, {}) - project_name = harvest_project.get('name', 'Imported Project') - + project_name = harvest_project.get("name", "Imported Project") + # Get client - harvest_client_id = harvest_project.get('client', {}).get('id') + harvest_client_id = harvest_project.get("client", {}).get("id") harvest_client = harvest_clients.get(harvest_client_id, {}) - client_name = harvest_client.get('name', project_name) - + client_name = harvest_client.get("name", project_name) + # Get or create client client = Client.query.filter_by(name=client_name).first() if not client: client = Client(name=client_name) db.session.add(client) db.session.flush() - + # Get or create project project = Project.query.filter_by(name=project_name, client_id=client.id).first() if not project: project = Project( - name=project_name, - client_id=client.id, - billable=harvest_project.get('is_billable', True) + name=project_name, client_id=client.id, billable=harvest_project.get("is_billable", True) ) db.session.add(project) db.session.flush() - + # Get or create task task = None - task_name = entry.get('task', {}).get('name') + task_name = entry.get("task", {}).get("name") if task_name: task = Task.query.filter_by(name=task_name, project_id=project.id).first() if not task: - task = Task( - name=task_name, - project_id=project.id, - status='in_progress' - ) + task = Task(name=task_name, project_id=project.id, status="in_progress") db.session.add(task) db.session.flush() - + # Parse times # Harvest provides date and hours - spent_date = datetime.strptime(entry['spent_date'], '%Y-%m-%d') - hours = float(entry.get('hours', 0)) - + spent_date = datetime.strptime(entry["spent_date"], "%Y-%m-%d") + hours = float(entry.get("hours", 0)) + # Create start/end times (use midday as default start time) start_time = spent_date.replace(hour=12, minute=0, second=0) duration_seconds = int(hours * 3600) end_time = start_time + timedelta(seconds=duration_seconds) - + # Create time entry time_entry = TimeEntry( user_id=user_id, @@ -469,26 +435,26 @@ def import_from_harvest(user_id, account_id, api_token, start_date, end_date, im start_time=start_time, end_time=end_time, duration_seconds=duration_seconds, - notes=entry.get('notes', ''), - billable=entry.get('billable', True), - source='harvest' + notes=entry.get("notes", ""), + billable=entry.get("billable", True), + source="harvest", ) - + db.session.add(time_entry) successful += 1 - + # Commit every 50 records if (idx + 1) % 50 == 0: db.session.commit() import_record.update_progress(total, successful, failed) - + except Exception as e: failed += 1 error_msg = f"Entry {idx + 1}: {str(e)}" errors.append(error_msg) import_record.add_error(error_msg, entry) db.session.rollback() - + # Final commit try: db.session.commit() @@ -496,76 +462,63 @@ def import_from_harvest(user_id, account_id, api_token, start_date, end_date, im db.session.rollback() import_record.fail(f"Failed to commit final changes: {str(e)}") raise ImportError(f"Failed to commit changes: {str(e)}") - + # Update import record import_record.update_progress(total, successful, failed) - + if failed == 0: import_record.complete() elif successful > 0: import_record.partial_complete() else: import_record.fail("All records failed to import") - - summary = { - 'total': total, - 'successful': successful, - 'failed': failed, - 'errors': errors[:10] - } + + summary = {"total": total, "successful": successful, "failed": failed, "errors": errors[:10]} import_record.set_summary(summary) - + return summary def restore_from_backup(user_id, backup_file_path): """ Restore data from a backup file - + Args: user_id: ID of the admin user performing restore backup_file_path: Path to backup JSON file - + Returns: Dictionary with restore statistics """ user = User.query.get(user_id) if not user or not user.is_admin: raise ImportError("Only admin users can restore from backup") - + # Load backup file try: - with open(backup_file_path, 'r', encoding='utf-8') as f: + with open(backup_file_path, "r", encoding="utf-8") as f: backup_data = json.load(f) except Exception as e: raise ImportError(f"Failed to load backup file: {str(e)}") - + # Validate backup format - if 'backup_info' not in backup_data: + if "backup_info" not in backup_data: raise ImportError("Invalid backup file format") - - statistics = { - 'users': 0, - 'clients': 0, - 'projects': 0, - 'time_entries': 0, - 'tasks': 0, - 'expenses': 0, - 'errors': [] - } - + + statistics = {"users": 0, "clients": 0, "projects": 0, "time_entries": 0, "tasks": 0, "expenses": 0, "errors": []} + # Note: This is a simplified restore. In production, you'd want more sophisticated # handling of conflicts, relationships, and potentially a transaction-based approach - + current_app.logger.info(f"Starting restore from backup by user {user.username}") - + return statistics def _parse_datetime(datetime_str): """ Parse datetime string in various formats - + Supports: - ISO 8601: 2024-01-01T12:00:00 - Date only: 2024-01-01 (assumes midnight) @@ -573,36 +526,35 @@ def _parse_datetime(datetime_str): """ if not datetime_str or not isinstance(datetime_str, str): return None - + datetime_str = datetime_str.strip() - + # Try common formats formats = [ - '%Y-%m-%d %H:%M:%S', - '%Y-%m-%dT%H:%M:%S', - '%Y-%m-%d %H:%M', - '%Y-%m-%dT%H:%M', - '%Y-%m-%d', - '%d/%m/%Y %H:%M:%S', - '%d/%m/%Y %H:%M', - '%d/%m/%Y', - '%m/%d/%Y %H:%M:%S', - '%m/%d/%Y %H:%M', - '%m/%d/%Y', + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d", + "%d/%m/%Y %H:%M:%S", + "%d/%m/%Y %H:%M", + "%d/%m/%Y", + "%m/%d/%Y %H:%M:%S", + "%m/%d/%Y %H:%M", + "%m/%d/%Y", ] - + for fmt in formats: try: return datetime.strptime(datetime_str, fmt) except ValueError: continue - + # Try ISO format with timezone try: - dt = datetime.fromisoformat(datetime_str.replace('Z', '+00:00')) + dt = datetime.fromisoformat(datetime_str.replace("Z", "+00:00")) return dt.replace(tzinfo=None) # Convert to naive datetime except: pass - - return None + return None diff --git a/app/utils/datetime_utils.py b/app/utils/datetime_utils.py index f4314ac5..6bd09ef0 100644 --- a/app/utils/datetime_utils.py +++ b/app/utils/datetime_utils.py @@ -11,17 +11,17 @@ def parse_date(date_str: str, format: Optional[str] = None) -> Optional[date]: """ Parse a date string to a date object. - + Args: date_str: Date string format: Optional format string (defaults to ISO format) - + Returns: date object or None if parsing fails """ if not date_str: return None - + try: if format: return datetime.strptime(date_str, format).date() @@ -31,7 +31,7 @@ def parse_date(date_str: str, format: Optional[str] = None) -> Optional[date]: return datetime.fromisoformat(date_str).date() except ValueError: # Try common formats - for fmt in ['%Y-%m-%d', '%d/%m/%Y', '%m/%d/%Y', '%Y/%m/%d']: + for fmt in ["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y", "%Y/%m/%d"]: try: return datetime.strptime(date_str, fmt).date() except ValueError: @@ -44,32 +44,27 @@ def parse_date(date_str: str, format: Optional[str] = None) -> Optional[date]: def parse_datetime(datetime_str: str, format: Optional[str] = None) -> Optional[datetime]: """ Parse a datetime string to a datetime object. - + Args: datetime_str: Datetime string format: Optional format string (defaults to ISO format) - + Returns: datetime object or None if parsing fails """ if not datetime_str: return None - + try: if format: return datetime.strptime(datetime_str, format) else: # Try ISO format first try: - return datetime.fromisoformat(datetime_str.replace('Z', '+00:00')) + return datetime.fromisoformat(datetime_str.replace("Z", "+00:00")) except ValueError: # Try common formats - for fmt in [ - '%Y-%m-%d %H:%M:%S', - '%Y-%m-%dT%H:%M:%S', - '%d/%m/%Y %H:%M:%S', - '%m/%d/%Y %H:%M:%S' - ]: + for fmt in ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%d/%m/%Y %H:%M:%S", "%m/%d/%Y %H:%M:%S"]: try: return datetime.strptime(datetime_str, fmt) except ValueError: @@ -79,114 +74,109 @@ def parse_datetime(datetime_str: str, format: Optional[str] = None) -> Optional[ return None -def format_date(d: date, format: str = '%Y-%m-%d') -> str: +def format_date(d: date, format: str = "%Y-%m-%d") -> str: """ Format a date object to a string. - + Args: d: date object format: Format string - + Returns: Formatted date string """ if not d: - return '' + return "" return d.strftime(format) -def format_datetime(dt: datetime, format: str = '%Y-%m-%d %H:%M:%S') -> str: +def format_datetime(dt: datetime, format: str = "%Y-%m-%d %H:%M:%S") -> str: """ Format a datetime object to a string. - + Args: dt: datetime object format: Format string - + Returns: Formatted datetime string """ if not dt: - return '' + return "" return dt.strftime(format) def get_date_range( - period: str = 'month', - start_date: Optional[date] = None, - end_date: Optional[date] = None + period: str = "month", start_date: Optional[date] = None, end_date: Optional[date] = None ) -> Tuple[date, date]: """ Get a date range for common periods. - + Args: period: Period type ('today', 'week', 'month', 'quarter', 'year', 'custom') start_date: Custom start date (for 'custom' period) end_date: Custom end date (for 'custom' period) - + Returns: tuple of (start_date, end_date) """ today = date.today() - - if period == 'today': + + if period == "today": return today, today - - elif period == 'week': + + elif period == "week": # Start of week (Monday) start = today - timedelta(days=today.weekday()) return start, today - - elif period == 'month': + + elif period == "month": start = today.replace(day=1) return start, today - - elif period == 'quarter': + + elif period == "quarter": quarter = (today.month - 1) // 3 start = date(today.year, quarter * 3 + 1, 1) return start, today - - elif period == 'year': + + elif period == "year": start = date(today.year, 1, 1) return start, today - - elif period == 'custom': + + elif period == "custom": if start_date and end_date: return start_date, end_date return today, today - + else: return today, today -def get_previous_period( - period: str = 'month', - reference_date: Optional[date] = None -) -> Tuple[date, date]: +def get_previous_period(period: str = "month", reference_date: Optional[date] = None) -> Tuple[date, date]: """ Get the previous period date range. - + Args: period: Period type ('week', 'month', 'quarter', 'year') reference_date: Reference date (defaults to today) - + Returns: tuple of (start_date, end_date) """ ref = reference_date or date.today() - - if period == 'week': + + if period == "week": start = ref - timedelta(days=ref.weekday() + 7) end = start + timedelta(days=6) return start, end - - elif period == 'month': + + elif period == "month": first_day = ref.replace(day=1) start = first_day - relativedelta(months=1) end = first_day - timedelta(days=1) return start, end - - elif period == 'quarter': + + elif period == "quarter": quarter = (ref.month - 1) // 3 start = date(ref.year, quarter * 3 + 1, 1) if quarter == 0: @@ -195,56 +185,53 @@ def get_previous_period( else: end = date(ref.year, quarter * 3, 1) - timedelta(days=1) return start, end - - elif period == 'year': + + elif period == "year": start = date(ref.year - 1, 1, 1) end = date(ref.year - 1, 12, 31) return start, end - + else: return ref, ref -def calculate_duration( - start: datetime, - end: datetime -) -> timedelta: +def calculate_duration(start: datetime, end: datetime) -> timedelta: """ Calculate duration between two datetimes. - + Args: start: Start datetime end: End datetime - + Returns: timedelta object """ if not start or not end: return timedelta(0) - + return end - start -def format_duration(seconds: float, format: str = 'hours') -> str: +def format_duration(seconds: float, format: str = "hours") -> str: """ Format duration in seconds to a human-readable string. - + Args: seconds: Duration in seconds format: Format type ('hours', 'detailed', 'short') - + Returns: Formatted duration string """ - if format == 'hours': + if format == "hours": hours = seconds / 3600 return f"{hours:.2f}h" - - elif format == 'detailed': + + elif format == "detailed": hours = int(seconds // 3600) minutes = int((seconds % 3600) // 60) secs = int(seconds % 60) - + parts = [] if hours > 0: parts.append(f"{hours}h") @@ -252,16 +239,16 @@ def format_duration(seconds: float, format: str = 'hours') -> str: parts.append(f"{minutes}m") if secs > 0 or not parts: parts.append(f"{secs}s") - + return " ".join(parts) - - elif format == 'short': + + elif format == "short": hours = seconds / 3600 if hours < 1: minutes = seconds / 60 return f"{int(minutes)}m" return f"{hours:.1f}h" - + else: return f"{seconds}s" @@ -269,10 +256,10 @@ def format_duration(seconds: float, format: str = 'hours') -> str: def is_business_day(d: date) -> bool: """ Check if a date is a business day (Monday-Friday). - + Args: d: date object - + Returns: True if business day, False otherwise """ @@ -282,32 +269,32 @@ def is_business_day(d: date) -> bool: def add_business_days(start_date: date, days: int) -> date: """ Add business days to a date. - + Args: start_date: Start date days: Number of business days to add - + Returns: Result date """ current = start_date added = 0 - + while added < days: current += timedelta(days=1) if is_business_day(current): added += 1 - + return current def get_week_start_end(d: date) -> Tuple[date, date]: """ Get the start (Monday) and end (Sunday) of the week for a date. - + Args: d: date object - + Returns: tuple of (week_start, week_end) """ @@ -319,10 +306,10 @@ def get_week_start_end(d: date) -> Tuple[date, date]: def get_month_start_end(d: date) -> Tuple[date, date]: """ Get the start and end of the month for a date. - + Args: d: date object - + Returns: tuple of (month_start, month_end) """ @@ -332,4 +319,3 @@ def get_month_start_end(d: date) -> Tuple[date, date]: else: month_end = date(d.year, d.month + 1, 1) - timedelta(days=1) return month_start, month_end - diff --git a/app/utils/db.py b/app/utils/db.py index 482fd4dc..5e2dfca0 100644 --- a/app/utils/db.py +++ b/app/utils/db.py @@ -29,9 +29,7 @@ def safe_commit(action: Optional[str] = None, context: Optional[Dict[str, Any]] e, ) else: - current_app.logger.exception( - "Database commit failed during %s | error=%s", action, e - ) + current_app.logger.exception("Database commit failed during %s | error=%s", action, e) else: current_app.logger.exception("Database commit failed: %s", e) except Exception: @@ -49,5 +47,3 @@ def safe_commit(action: Optional[str] = None, context: Optional[Dict[str, Any]] except Exception: pass return False - - diff --git a/app/utils/email.py b/app/utils/email.py index db66382b..00db8201 100644 --- a/app/utils/email.py +++ b/app/utils/email.py @@ -14,62 +14,67 @@ def init_mail(app): """Initialize Flask-Mail with the app - + Checks for database settings first, then falls back to environment variables. Database settings persist between restarts and updates. """ # First, load defaults from environment variables (as fallback) - app.config['MAIL_SERVER'] = os.getenv('MAIL_SERVER', 'localhost') - app.config['MAIL_PORT'] = int(os.getenv('MAIL_PORT', 587)) - app.config['MAIL_USE_TLS'] = os.getenv('MAIL_USE_TLS', 'true').lower() == 'true' - app.config['MAIL_USE_SSL'] = os.getenv('MAIL_USE_SSL', 'false').lower() == 'true' - app.config['MAIL_USERNAME'] = os.getenv('MAIL_USERNAME') - app.config['MAIL_PASSWORD'] = os.getenv('MAIL_PASSWORD') - app.config['MAIL_DEFAULT_SENDER'] = os.getenv('MAIL_DEFAULT_SENDER', 'noreply@timetracker.local') - app.config['MAIL_MAX_EMAILS'] = int(os.getenv('MAIL_MAX_EMAILS', 100)) - + app.config["MAIL_SERVER"] = os.getenv("MAIL_SERVER", "localhost") + app.config["MAIL_PORT"] = int(os.getenv("MAIL_PORT", 587)) + app.config["MAIL_USE_TLS"] = os.getenv("MAIL_USE_TLS", "true").lower() == "true" + app.config["MAIL_USE_SSL"] = os.getenv("MAIL_USE_SSL", "false").lower() == "true" + app.config["MAIL_USERNAME"] = os.getenv("MAIL_USERNAME") + app.config["MAIL_PASSWORD"] = os.getenv("MAIL_PASSWORD") + app.config["MAIL_DEFAULT_SENDER"] = os.getenv("MAIL_DEFAULT_SENDER", "noreply@timetracker.local") + app.config["MAIL_MAX_EMAILS"] = int(os.getenv("MAIL_MAX_EMAILS", 100)) + # Check if database settings should override environment variables # Database settings persist between restarts and updates try: from app.models import Settings from app import db - + if db.session.is_active: settings = Settings.get_settings() db_config = settings.get_mail_config() - + if db_config: # Database settings take precedence and persist between restarts app.config.update(db_config) - app.logger.info(f"✓ Using database email configuration (persistent): {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}") + app.logger.info( + f"✓ Using database email configuration (persistent): {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}" + ) else: app.logger.info("Using environment variable email configuration (database email not enabled)") except Exception as e: # If database is not available, fall back to environment variables app.logger.debug(f"Could not load email settings from database: {e}") app.logger.info("Using environment variable email configuration (database unavailable)") - + mail.init_app(app) return mail def reload_mail_config(app): """Reload email configuration from database - + Call this after updating email settings in the database to apply changes. Database settings persist between restarts and updates. """ try: from app.models import Settings + settings = Settings.get_settings() db_config = settings.get_mail_config() - + if db_config: # Update app configuration with latest database settings app.config.update(db_config) # Reinitialize mail with new config (this ensures mail object uses latest settings) mail.init_app(app) - app.logger.info(f"✓ Email configuration reloaded from database: {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}") + app.logger.info( + f"✓ Email configuration reloaded from database: {db_config.get('MAIL_SERVER')}:{db_config.get('MAIL_PORT')}" + ) return True else: app.logger.info("No database email configuration found, using environment variables") @@ -90,11 +95,11 @@ def send_async_email(app, msg): def send_client_portal_password_setup_email(client, token): """Send password setup email to client - + Args: client: Client object token: Password setup token - + Returns: bool: True if email sent successfully, False otherwise """ @@ -102,38 +107,36 @@ def send_client_portal_password_setup_email(client, token): if not client.email: current_app.logger.warning(f"Cannot send password setup email to client {client.name}: no email address") return False - + # Always check database settings first (they take precedence) from app.models import Settings + settings = Settings.get_settings() db_config = settings.get_mail_config() - + # Use database config if available, otherwise fall back to app config if db_config: - mail_server = db_config.get('MAIL_SERVER') - mail_default_sender = db_config.get('MAIL_DEFAULT_SENDER') + mail_server = db_config.get("MAIL_SERVER") + mail_default_sender = db_config.get("MAIL_DEFAULT_SENDER") # Reload mail config to ensure we're using latest database settings reload_mail_config(current_app._get_current_object()) else: - mail_server = current_app.config.get('MAIL_SERVER') - mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER') - + mail_server = current_app.config.get("MAIL_SERVER") + mail_default_sender = current_app.config.get("MAIL_DEFAULT_SENDER") + # Check if email is configured - if not mail_server or mail_server == 'localhost': + if not mail_server or mail_server == "localhost": current_app.logger.error("Mail server not configured. Cannot send password setup email.") return False - + # Generate password setup URL - setup_url = url_for('client_portal.set_password', token=token, _external=True) - + setup_url = url_for("client_portal.set_password", token=token, _external=True) + # Render email template html_body = render_template( - 'email/client_portal_password_setup.html', - client=client, - setup_url=setup_url, - token=token + "email/client_portal_password_setup.html", client=client, setup_url=setup_url, token=token ) - + # Plain text version text_body = f""" Hello {client.name or client.contact_person or 'Client'}, @@ -150,27 +153,29 @@ def send_client_portal_password_setup_email(client, token): Best regards, TimeTracker Team """ - + subject = f"Set Your Client Portal Password - {client.name}" - + # Create message msg = Message( subject=subject, recipients=[client.email], body=text_body, html=html_body, - sender=mail_default_sender or current_app.config.get('MAIL_DEFAULT_SENDER', 'noreply@timetracker.local') + sender=mail_default_sender or current_app.config.get("MAIL_DEFAULT_SENDER", "noreply@timetracker.local"), ) - + # Send synchronously to catch errors try: mail.send(msg) - current_app.logger.info(f"Password setup email sent successfully to {client.email} for client {client.name}") + current_app.logger.info( + f"Password setup email sent successfully to {client.email} for client {client.name}" + ) return True except Exception as send_error: current_app.logger.error(f"Failed to send password setup email: {send_error}") return False - + except Exception as e: current_app.logger.error(f"Failed to prepare password setup email: {e}") return False @@ -178,7 +183,7 @@ def send_client_portal_password_setup_email(client, token): def send_email(subject, recipients, text_body, html_body=None, sender=None, attachments=None): """Send an email - + Args: subject: Email subject line recipients: List of recipient email addresses @@ -189,56 +194,59 @@ def send_email(subject, recipients, text_body, html_body=None, sender=None, atta """ # Always check database settings first (they take precedence) from app.models import Settings + settings = Settings.get_settings() db_config = settings.get_mail_config() - + # Use database config if available, otherwise fall back to app config if db_config: - mail_server = db_config.get('MAIL_SERVER') - mail_default_sender = db_config.get('MAIL_DEFAULT_SENDER') + mail_server = db_config.get("MAIL_SERVER") + mail_default_sender = db_config.get("MAIL_DEFAULT_SENDER") else: - mail_server = current_app.config.get('MAIL_SERVER') - mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER') - - if not mail_server or mail_server == 'localhost': + mail_server = current_app.config.get("MAIL_SERVER") + mail_default_sender = current_app.config.get("MAIL_DEFAULT_SENDER") + + if not mail_server or mail_server == "localhost": current_app.logger.warning("Mail server not configured, skipping email send") return - + if not recipients: current_app.logger.warning("No recipients specified for email") return - + msg = Message( subject=subject, recipients=recipients if isinstance(recipients, list) else [recipients], body=text_body, html=html_body, - sender=sender or mail_default_sender or current_app.config.get('MAIL_DEFAULT_SENDER', 'noreply@timetracker.local') + sender=sender + or mail_default_sender + or current_app.config.get("MAIL_DEFAULT_SENDER", "noreply@timetracker.local"), ) - + # Add attachments if provided if attachments: for filename, content_type, data in attachments: msg.attach(filename, content_type, data) - + # Send asynchronously Thread(target=send_async_email, args=(current_app._get_current_object(), msg)).start() def send_overdue_invoice_notification(invoice, user): """Send notification about an overdue invoice - + Args: invoice: Invoice object user: User object (invoice creator or admin) """ if not user.email or not user.email_notifications or not user.notification_overdue_invoices: return - + days_overdue = (datetime.utcnow().date() - invoice.due_date).days - + subject = f"Invoice {invoice.invoice_number} is {days_overdue} days overdue" - + text_body = f""" Hello {user.display_name}, @@ -258,20 +266,15 @@ def send_overdue_invoice_notification(invoice, user): --- TimeTracker - Time Tracking & Project Management """ - - html_body = render_template( - 'email/overdue_invoice.html', - user=user, - invoice=invoice, - days_overdue=days_overdue - ) - + + html_body = render_template("email/overdue_invoice.html", user=user, invoice=invoice, days_overdue=days_overdue) + send_email(subject, user.email, text_body, html_body) def send_task_assigned_notification(task, user, assigned_by): """Send notification when a user is assigned to a task - + Args: task: Task object user: User who was assigned @@ -279,9 +282,9 @@ def send_task_assigned_notification(task, user, assigned_by): """ if not user.email or not user.email_notifications or not user.notification_task_assigned: return - + subject = f"You've been assigned to task: {task.name}" - + text_body = f""" Hello {user.display_name}, @@ -302,20 +305,15 @@ def send_task_assigned_notification(task, user, assigned_by): --- TimeTracker - Time Tracking & Project Management """ - - html_body = render_template( - 'email/task_assigned.html', - user=user, - task=task, - assigned_by=assigned_by - ) - + + html_body = render_template("email/task_assigned.html", user=user, task=task, assigned_by=assigned_by) + send_email(subject, user.email, text_body, html_body) def send_weekly_summary(user, start_date, end_date, hours_worked, projects_data): """Send weekly time tracking summary to user - + Args: user: User object start_date: Start of the week @@ -325,15 +323,12 @@ def send_weekly_summary(user, start_date, end_date, hours_worked, projects_data) """ if not user.email or not user.email_notifications or not user.notification_weekly_summary: return - + subject = f"Your Weekly Time Summary ({start_date} to {end_date})" - + # Build project summary text - project_summary = "\n".join([ - f"- {p['name']}: {p['hours']:.1f} hours" - for p in projects_data - ]) - + project_summary = "\n".join([f"- {p['name']}: {p['hours']:.1f} hours" for p in projects_data]) + text_body = f""" Hello {user.display_name}, @@ -351,22 +346,22 @@ def send_weekly_summary(user, start_date, end_date, hours_worked, projects_data) --- TimeTracker - Time Tracking & Project Management """ - + html_body = render_template( - 'email/weekly_summary.html', + "email/weekly_summary.html", user=user, start_date=start_date, end_date=end_date, hours_worked=hours_worked, - projects_data=projects_data + projects_data=projects_data, ) - + send_email(subject, user.email, text_body, html_body) def send_comment_notification(comment, task, mentioned_users): """Send notification about a new comment - + Args: comment: Comment object task: Task the comment is on @@ -375,9 +370,9 @@ def send_comment_notification(comment, task, mentioned_users): for user in mentioned_users: if not user.email or not user.email_notifications or not user.notification_task_comments: continue - + subject = f"You were mentioned in a comment on: {task.name}" - + text_body = f""" Hello {user.display_name}, @@ -394,37 +389,33 @@ def send_comment_notification(comment, task, mentioned_users): --- TimeTracker - Time Tracking & Project Management """ - - html_body = render_template( - 'email/comment_mention.html', - user=user, - comment=comment, - task=task - ) - + + html_body = render_template("email/comment_mention.html", user=user, comment=comment, task=task) + send_email(subject, user.email, text_body, html_body) def check_email_configuration(): """Check email configuration and return status - + Returns: dict: Status information with 'configured', 'settings', 'errors', 'source' keys """ status = { - 'configured': False, - 'settings': {}, - 'errors': [], - 'warnings': [], - 'source': 'environment' # or 'database' + "configured": False, + "settings": {}, + "errors": [], + "warnings": [], + "source": "environment", # or 'database' } - + # Check if database configuration is enabled try: from app.models import Settings + settings = Settings.get_settings() if settings.mail_enabled and settings.mail_server: - status['source'] = 'database' + status["source"] = "database" mail_server = settings.mail_server mail_port = settings.mail_port mail_username = settings.mail_username @@ -434,79 +425,79 @@ def check_email_configuration(): mail_default_sender = settings.mail_default_sender else: # Use environment/app config - mail_server = current_app.config.get('MAIL_SERVER') - mail_port = current_app.config.get('MAIL_PORT') - mail_username = current_app.config.get('MAIL_USERNAME') - mail_password = current_app.config.get('MAIL_PASSWORD') - mail_use_tls = current_app.config.get('MAIL_USE_TLS') - mail_use_ssl = current_app.config.get('MAIL_USE_SSL') - mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER') + mail_server = current_app.config.get("MAIL_SERVER") + mail_port = current_app.config.get("MAIL_PORT") + mail_username = current_app.config.get("MAIL_USERNAME") + mail_password = current_app.config.get("MAIL_PASSWORD") + mail_use_tls = current_app.config.get("MAIL_USE_TLS") + mail_use_ssl = current_app.config.get("MAIL_USE_SSL") + mail_default_sender = current_app.config.get("MAIL_DEFAULT_SENDER") except Exception: # Fall back to app config if database not available - mail_server = current_app.config.get('MAIL_SERVER') - mail_port = current_app.config.get('MAIL_PORT') - mail_username = current_app.config.get('MAIL_USERNAME') - mail_password = current_app.config.get('MAIL_PASSWORD') - mail_use_tls = current_app.config.get('MAIL_USE_TLS') - mail_use_ssl = current_app.config.get('MAIL_USE_SSL') - mail_default_sender = current_app.config.get('MAIL_DEFAULT_SENDER') - - status['settings'] = { - 'server': mail_server or 'Not configured', - 'port': mail_port or 'Not configured', - 'username': mail_username or 'Not configured', - 'password_set': bool(mail_password), - 'use_tls': mail_use_tls, - 'use_ssl': mail_use_ssl, - 'default_sender': mail_default_sender or 'Not configured' + mail_server = current_app.config.get("MAIL_SERVER") + mail_port = current_app.config.get("MAIL_PORT") + mail_username = current_app.config.get("MAIL_USERNAME") + mail_password = current_app.config.get("MAIL_PASSWORD") + mail_use_tls = current_app.config.get("MAIL_USE_TLS") + mail_use_ssl = current_app.config.get("MAIL_USE_SSL") + mail_default_sender = current_app.config.get("MAIL_DEFAULT_SENDER") + + status["settings"] = { + "server": mail_server or "Not configured", + "port": mail_port or "Not configured", + "username": mail_username or "Not configured", + "password_set": bool(mail_password), + "use_tls": mail_use_tls, + "use_ssl": mail_use_ssl, + "default_sender": mail_default_sender or "Not configured", } - + # Check for configuration issues - if not mail_server or mail_server == 'localhost': - status['errors'].append('Mail server not configured or set to localhost') - - if not mail_default_sender or mail_default_sender == 'noreply@timetracker.local': - status['warnings'].append('Default sender email should be configured with a real email address') - + if not mail_server or mail_server == "localhost": + status["errors"].append("Mail server not configured or set to localhost") + + if not mail_default_sender or mail_default_sender == "noreply@timetracker.local": + status["warnings"].append("Default sender email should be configured with a real email address") + if mail_use_tls and mail_use_ssl: - status['errors'].append('Cannot use both TLS and SSL. Choose one.') - - if not mail_username and mail_server not in ['localhost', '127.0.0.1']: - status['warnings'].append('MAIL_USERNAME not set (may be required for authentication)') - + status["errors"].append("Cannot use both TLS and SSL. Choose one.") + + if not mail_username and mail_server not in ["localhost", "127.0.0.1"]: + status["warnings"].append("MAIL_USERNAME not set (may be required for authentication)") + if not mail_password and mail_username: - status['warnings'].append('MAIL_PASSWORD not set but MAIL_USERNAME is configured') - + status["warnings"].append("MAIL_PASSWORD not set but MAIL_USERNAME is configured") + # Mark as configured if minimum requirements are met - status['configured'] = bool(mail_server and mail_server != 'localhost' and not status['errors']) - + status["configured"] = bool(mail_server and mail_server != "localhost" and not status["errors"]) + return status -def send_test_email(recipient_email, sender_name='TimeTracker Admin'): +def send_test_email(recipient_email, sender_name="TimeTracker Admin"): """Send a test email to verify email configuration - + Args: recipient_email: Email address to send test email to sender_name: Name of the sender - + Returns: tuple: (success: bool, message: str) """ try: current_app.logger.info(f"[EMAIL TEST] Starting test email send to: {recipient_email}") - + # Validate recipient email - if not recipient_email or '@' not in recipient_email: + if not recipient_email or "@" not in recipient_email: current_app.logger.warning(f"[EMAIL TEST] Invalid recipient email: {recipient_email}") - return False, 'Invalid recipient email address' - + return False, "Invalid recipient email address" + # Check if mail is configured - mail_server = current_app.config.get('MAIL_SERVER') + mail_server = current_app.config.get("MAIL_SERVER") if not mail_server: current_app.logger.error("[EMAIL TEST] Mail server not configured") - return False, 'Mail server not configured. Please set MAIL_SERVER in environment variables.' - + return False, "Mail server not configured. Please set MAIL_SERVER in environment variables." + # Log current configuration current_app.logger.info(f"[EMAIL TEST] Configuration:") current_app.logger.info(f" - Server: {mail_server}:{current_app.config.get('MAIL_PORT')}") @@ -514,9 +505,9 @@ def send_test_email(recipient_email, sender_name='TimeTracker Admin'): current_app.logger.info(f" - SSL: {current_app.config.get('MAIL_USE_SSL')}") current_app.logger.info(f" - Username: {current_app.config.get('MAIL_USERNAME')}") current_app.logger.info(f" - Sender: {current_app.config.get('MAIL_DEFAULT_SENDER')}") - - subject = 'TimeTracker Email Test' - + + subject = "TimeTracker Email Test" + text_body = f""" Hello, @@ -534,23 +525,23 @@ def send_test_email(recipient_email, sender_name='TimeTracker Admin'): --- TimeTracker - Time Tracking & Project Management """ - + try: html_body = render_template( - 'email/test_email.html', + "email/test_email.html", sender_name=sender_name, - mail_server=current_app.config.get('MAIL_SERVER'), - mail_port=current_app.config.get('MAIL_PORT'), - use_tls=current_app.config.get('MAIL_USE_TLS'), - use_ssl=current_app.config.get('MAIL_USE_SSL'), - datetime=datetime + mail_server=current_app.config.get("MAIL_SERVER"), + mail_port=current_app.config.get("MAIL_PORT"), + use_tls=current_app.config.get("MAIL_USE_TLS"), + use_ssl=current_app.config.get("MAIL_USE_SSL"), + datetime=datetime, ) current_app.logger.info("[EMAIL TEST] HTML template rendered successfully") except Exception as template_error: # If template doesn't exist, use text only current_app.logger.warning(f"[EMAIL TEST] HTML template not available: {template_error}") html_body = None - + # Create message current_app.logger.info("[EMAIL TEST] Creating email message") msg = Message( @@ -558,46 +549,47 @@ def send_test_email(recipient_email, sender_name='TimeTracker Admin'): recipients=[recipient_email], body=text_body, html=html_body, - sender=current_app.config['MAIL_DEFAULT_SENDER'] + sender=current_app.config["MAIL_DEFAULT_SENDER"], ) - + # Send synchronously for testing (so we can catch errors) current_app.logger.info("[EMAIL TEST] Attempting to send email via SMTP...") mail.send(msg) current_app.logger.info(f"[EMAIL TEST] ✓ Email sent successfully to {recipient_email}") - - return True, f'Test email sent successfully to {recipient_email}' - + + return True, f"Test email sent successfully to {recipient_email}" + except Exception as e: current_app.logger.error(f"[EMAIL TEST] ✗ Failed to send test email: {type(e).__name__}: {str(e)}") current_app.logger.exception("[EMAIL TEST] Full exception trace:") - return False, f'Failed to send test email: {str(e)}' + return False, f"Failed to send test email: {str(e)}" def send_invoice_email(invoice, recipient_email, sender_user=None, custom_message=None, email_template_id=None): """Send an invoice via email with PDF attachment - + Args: invoice: Invoice object recipient_email: Email address to send to sender_user: User object who is sending (for tracking) custom_message: Optional custom message to include in email email_template_id: Optional email template ID to use - + Returns: tuple: (success: bool, invoice_email: InvoiceEmail or None, message: str) """ try: from app.models import InvoiceEmail, Settings - + current_app.logger.info(f"[INVOICE EMAIL] Sending invoice {invoice.invoice_number} to {recipient_email}") - + # Generate PDF pdf_bytes = None try: from app.utils.pdf_generator import InvoicePDFGenerator + settings = Settings.get_settings() - pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size='A4') + pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size="A4") pdf_bytes = pdf_generator.generate_pdf() if not pdf_bytes: raise ValueError("PDF generator returned None") @@ -607,6 +599,7 @@ def send_invoice_email(invoice, recipient_email, sender_user=None, custom_messag current_app.logger.exception("[INVOICE EMAIL] PDF generation error details:") try: from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback + settings = Settings.get_settings() pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings) pdf_bytes = pdf_generator.generate_pdf() @@ -616,53 +609,56 @@ def send_invoice_email(invoice, recipient_email, sender_user=None, custom_messag except Exception as fallback_error: current_app.logger.error(f"[INVOICE EMAIL] Both PDF generators failed: {fallback_error}") current_app.logger.exception("[INVOICE EMAIL] Fallback PDF generation error details:") - return False, None, f'PDF generation failed: {str(fallback_error)}' - + return False, None, f"PDF generation failed: {str(fallback_error)}" + if not pdf_bytes: current_app.logger.error("[INVOICE EMAIL] PDF bytes is None after generation") - return False, None, 'PDF generation returned empty result' - + return False, None, "PDF generation returned empty result" + # Get settings for email subject/body settings = Settings.get_settings() - company_name = settings.company_name if settings else 'Your Company' - + company_name = settings.company_name if settings else "Your Company" + # Create email subject subject = f"Invoice {invoice.invoice_number} from {company_name}" - + # Get email template if specified html_body = None text_body = None - + if email_template_id: try: from app.models import InvoiceTemplate + email_template = InvoiceTemplate.query.get(email_template_id) if email_template and email_template.html: # Use custom template # Ensure the HTML is properly formatted for email template_html = email_template.html.strip() - + # If CSS is provided separately, wrap it in ' + if not css_content.startswith("\n{css_content}\n" # Insert CSS into HTML if not already present - if '' not in template_html: + if "" not in template_html: # Try to insert before or at the beginning if no head tag - if '' in template_html: - template_html = template_html.replace('', f'{css_content}\n') - elif '' in template_html: - template_html = template_html.replace('', f'{css_content}\n') + if "" in template_html: + template_html = template_html.replace("", f"{css_content}\n") + elif "" in template_html: + template_html = template_html.replace("", f"{css_content}\n") else: - template_html = f'{css_content}\n{template_html}' - + template_html = f"{css_content}\n{template_html}" + # Ensure HTML has proper structure - if not template_html.strip().startswith(' + if " @@ -670,20 +666,18 @@ def send_invoice_email(invoice, recipient_email, sender_user=None, custom_messag {template_html} -''' - +""" + template = JinjaTemplate(template_html) html_body = template.render( - invoice=invoice, - company_name=company_name, - custom_message=custom_message + invoice=invoice, company_name=company_name, custom_message=custom_message ) # Generate text version from HTML if needed text_body = f"Invoice {invoice.invoice_number} - Please see attached PDF for details." except Exception as template_error: current_app.logger.warning(f"[INVOICE EMAIL] Custom template failed: {template_error}") current_app.logger.exception("[INVOICE EMAIL] Template error details:") - + # Fallback to default template if not html_body: # Create email body @@ -699,10 +693,10 @@ def send_invoice_email(invoice, recipient_email, sender_user=None, custom_messag - Amount: {invoice.currency_code} {invoice.total_amount} """ - + if custom_message: text_body += f"\n{custom_message}\n\n" - + text_body += f""" Please remit payment by the due date. @@ -711,106 +705,103 @@ def send_invoice_email(invoice, recipient_email, sender_user=None, custom_messag --- {company_name} """ - + # Render HTML template try: html_body = render_template( - 'email/invoice.html', - invoice=invoice, - company_name=company_name, - custom_message=custom_message + "email/invoice.html", invoice=invoice, company_name=company_name, custom_message=custom_message ) except Exception as template_error: current_app.logger.warning(f"[INVOICE EMAIL] HTML template not available: {template_error}") html_body = None - + # Get sender user ID sender_id = sender_user.id if sender_user else None if not sender_id: # Try to get from invoice creator sender_id = invoice.created_by - + # Send email synchronously to catch errors - attachments = [ - (f'invoice_{invoice.invoice_number}.pdf', 'application/pdf', pdf_bytes) - ] - + attachments = [(f"invoice_{invoice.invoice_number}.pdf", "application/pdf", pdf_bytes)] + # Create message msg = Message( subject=subject, recipients=[recipient_email], body=text_body, html=html_body, - sender=current_app.config['MAIL_DEFAULT_SENDER'] + sender=current_app.config["MAIL_DEFAULT_SENDER"], ) - + # Add attachments for filename, content_type, data in attachments: msg.attach(filename, content_type, data) - + # Send synchronously to catch errors try: current_app.logger.info(f"[INVOICE EMAIL] Attempting to send email to {recipient_email}") - current_app.logger.debug(f"[INVOICE EMAIL] Email config - Server: {current_app.config.get('MAIL_SERVER')}, Port: {current_app.config.get('MAIL_PORT')}") + current_app.logger.debug( + f"[INVOICE EMAIL] Email config - Server: {current_app.config.get('MAIL_SERVER')}, Port: {current_app.config.get('MAIL_PORT')}" + ) mail.send(msg) current_app.logger.info(f"[INVOICE EMAIL] ✓ Email sent successfully to {recipient_email}") except Exception as send_error: - current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}") + current_app.logger.error( + f"[INVOICE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}" + ) current_app.logger.exception("[INVOICE EMAIL] Email send error details:") raise send_error - + # Create email tracking record invoice_email = InvoiceEmail( - invoice_id=invoice.id, - recipient_email=recipient_email, - subject=subject, - sent_by=sender_id + invoice_id=invoice.id, recipient_email=recipient_email, subject=subject, sent_by=sender_id ) db.session.add(invoice_email) - + # Update invoice status to 'sent' if it's still 'draft' - if invoice.status == 'draft': - invoice.status = 'sent' - + if invoice.status == "draft": + invoice.status = "sent" + db.session.commit() - - return True, invoice_email, f'Invoice email sent successfully to {recipient_email}' - + + return True, invoice_email, f"Invoice email sent successfully to {recipient_email}" + except Exception as e: current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send invoice email: {type(e).__name__}: {str(e)}") current_app.logger.exception("[INVOICE EMAIL] Full exception trace:") - + # Try to create failed tracking record try: from app.models import InvoiceEmail + sender_id = sender_user.id if sender_user else invoice.created_by invoice_email = InvoiceEmail( invoice_id=invoice.id, recipient_email=recipient_email, subject=f"Invoice {invoice.invoice_number}", - sent_by=sender_id + sent_by=sender_id, ) invoice_email.mark_failed(str(e)) db.session.add(invoice_email) db.session.commit() except Exception: db.session.rollback() - - return False, None, f'Failed to send invoice email: {str(e)}' + + return False, None, f"Failed to send invoice email: {str(e)}" def send_quote_sent_notification(quote, user): """Send notification when a quote is sent to client - + Args: quote: Quote object user: User object (quote creator or admin) """ if not user.email or not user.email_notifications: return - + subject = f"Quote {quote.quote_number} has been sent to {quote.client.name if quote.client else 'client'}" - + text_body = f""" Hello {user.display_name or user.username}, @@ -828,28 +819,24 @@ def send_quote_sent_notification(quote, user): --- TimeTracker - Time Tracking & Project Management """ - - html_body = render_template( - 'email/quote_sent.html', - user=user, - quote=quote - ) - + + html_body = render_template("email/quote_sent.html", user=user, quote=quote) + send_email(subject, user.email, text_body, html_body) def send_quote_accepted_notification(quote, user): """Send notification when a quote is accepted - + Args: quote: Quote object user: User object (quote creator or admin) """ if not user.email or not user.email_notifications: return - + subject = f"Quote {quote.quote_number} has been accepted" - + text_body = f""" Hello {user.display_name or user.username}, @@ -868,28 +855,24 @@ def send_quote_accepted_notification(quote, user): --- TimeTracker - Time Tracking & Project Management """ - - html_body = render_template( - 'email/quote_accepted.html', - user=user, - quote=quote - ) - + + html_body = render_template("email/quote_accepted.html", user=user, quote=quote) + send_email(subject, user.email, text_body, html_body) def send_quote_rejected_notification(quote, user): """Send notification when a quote is rejected - + Args: quote: Quote object user: User object (quote creator or admin) """ if not user.email or not user.email_notifications: return - + subject = f"Quote {quote.quote_number} has been rejected" - + text_body = f""" Hello {user.display_name or user.username}, @@ -907,28 +890,24 @@ def send_quote_rejected_notification(quote, user): --- TimeTracker - Time Tracking & Project Management """ - - html_body = render_template( - 'email/quote_rejected.html', - user=user, - quote=quote - ) - + + html_body = render_template("email/quote_rejected.html", user=user, quote=quote) + send_email(subject, user.email, text_body, html_body) def send_quote_expired_notification(quote, user): """Send notification when a quote expires - + Args: quote: Quote object user: User object (quote creator or admin) """ if not user.email or not user.email_notifications: return - + subject = f"Quote {quote.quote_number} has expired" - + text_body = f""" Hello {user.display_name or user.username}, @@ -948,25 +927,21 @@ def send_quote_expired_notification(quote, user): --- TimeTracker - Time Tracking & Project Management """ - - html_body = render_template( - 'email/quote_expired.html', - user=user, - quote=quote - ) - + + html_body = render_template("email/quote_expired.html", user=user, quote=quote) + send_email(subject, user.email, text_body, html_body) def send_quote_email(quote, recipient_email, sender_user=None, custom_message=None): """Send a quote via email with PDF attachment - + Args: quote: Quote object recipient_email: Email address to send to sender_user: User object who is sending (for tracking) custom_message: Optional custom message to include in email - + Returns: tuple: (success: bool, message: str) """ @@ -976,40 +951,42 @@ def send_quote_email(quote, recipient_email, sender_user=None, custom_message=No from flask_mail import Message from flask import render_template from app import mail, db - + current_app.logger.info(f"[QUOTE EMAIL] Sending quote {quote.quote_number} to {recipient_email}") - + # Generate PDF pdf_bytes = None try: # Try to use QuotePDFGenerator if it exists try: from app.utils.pdf_generator import QuotePDFGenerator + settings = Settings.get_settings() - pdf_generator = QuotePDFGenerator(quote, settings=settings, page_size='A4') + pdf_generator = QuotePDFGenerator(quote, settings=settings, page_size="A4") pdf_bytes = pdf_generator.generate_pdf() except ImportError: # Fallback to simple PDF generation from app.utils.pdf_generator_fallback import QuotePDFGeneratorFallback + settings = Settings.get_settings() pdf_generator = QuotePDFGeneratorFallback(quote, settings=settings) pdf_bytes = pdf_generator.generate_pdf() - + if not pdf_bytes: raise ValueError("PDF generator returned None") current_app.logger.info(f"[QUOTE EMAIL] PDF generated successfully - size: {len(pdf_bytes)} bytes") except Exception as pdf_error: current_app.logger.error(f"[QUOTE EMAIL] PDF generation failed: {pdf_error}") current_app.logger.exception("[QUOTE EMAIL] PDF generation error details:") - return False, f'PDF generation failed: {str(pdf_error)}' - + return False, f"PDF generation failed: {str(pdf_error)}" + # Get settings for email subject/body settings = Settings.get_settings() - company_name = settings.company_name if settings else 'Your Company' - + company_name = settings.company_name if settings else "Your Company" + # Create email subject subject = f"Quote {quote.quote_number} from {company_name}" - + # Create email body text_body = f""" Hello, @@ -1023,10 +1000,10 @@ def send_quote_email(quote, recipient_email, sender_user=None, custom_message=No - Amount: {quote.currency_code} {quote.total_amount} """ - + if custom_message: text_body += f"\n{custom_message}\n\n" - + text_body += f""" Please review the attached quote and let us know if you have any questions. @@ -1035,56 +1012,52 @@ def send_quote_email(quote, recipient_email, sender_user=None, custom_message=No --- {company_name} """ - + # Render HTML template html_body = None try: html_body = render_template( - 'email/quote.html', - quote=quote, - company_name=company_name, - custom_message=custom_message + "email/quote.html", quote=quote, company_name=company_name, custom_message=custom_message ) except Exception as template_error: current_app.logger.warning(f"[QUOTE EMAIL] HTML template not available: {template_error}") html_body = None - + # Send email synchronously to catch errors - attachments = [ - (f'quote_{quote.quote_number}.pdf', 'application/pdf', pdf_bytes) - ] - + attachments = [(f"quote_{quote.quote_number}.pdf", "application/pdf", pdf_bytes)] + # Create message msg = Message( subject=subject, recipients=[recipient_email], body=text_body, html=html_body, - sender=current_app.config['MAIL_DEFAULT_SENDER'] + sender=current_app.config["MAIL_DEFAULT_SENDER"], ) - + # Add attachments for filename, content_type, data in attachments: msg.attach(filename, content_type, data) - + # Send synchronously to catch errors try: current_app.logger.info(f"[QUOTE EMAIL] Attempting to send email to {recipient_email}") mail.send(msg) current_app.logger.info(f"[QUOTE EMAIL] ✓ Email sent successfully to {recipient_email}") except Exception as send_error: - current_app.logger.error(f"[QUOTE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}") + current_app.logger.error( + f"[QUOTE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}" + ) current_app.logger.exception("[QUOTE EMAIL] Email send error details:") raise send_error - + # Mark quote as sent if it's still draft - if quote.status == 'draft': + if quote.status == "draft": quote.send() db.session.commit() - - return True, 'Email sent successfully' + + return True, "Email sent successfully" except Exception as e: current_app.logger.error(f"[QUOTE EMAIL] Exception in send_quote_email: {e}", exc_info=True) db.session.rollback() - return False, f'Failed to send email: {str(e)}' - + return False, f"Failed to send email: {str(e)}" diff --git a/app/utils/env_validation.py b/app/utils/env_validation.py index a4e630bb..6685515a 100644 --- a/app/utils/env_validation.py +++ b/app/utils/env_validation.py @@ -10,189 +10,169 @@ class EnvValidationError(Exception): """Raised when environment validation fails""" + pass def validate_required_env_vars(required_vars: List[str], raise_on_error: bool = True) -> Tuple[bool, List[str]]: """ Validate that required environment variables are set. - + Args: required_vars: List of required environment variable names raise_on_error: If True, raise EnvValidationError on failure - + Returns: Tuple of (is_valid, missing_vars) """ missing = [] for var in required_vars: value = os.getenv(var) - if not value or value.strip() == '': + if not value or value.strip() == "": missing.append(var) - + if missing and raise_on_error: - raise EnvValidationError( - f"Missing required environment variables: {', '.join(missing)}" - ) - + raise EnvValidationError(f"Missing required environment variables: {', '.join(missing)}") + return len(missing) == 0, missing def validate_secret_key() -> bool: """ Validate that SECRET_KEY is set and secure. - + Returns: True if valid, False otherwise """ - secret_key = os.getenv('SECRET_KEY', '') - placeholder_values = { - 'dev-secret-key-change-in-production', - 'your-secret-key-change-this', - 'your-secret-key-here' - } - + secret_key = os.getenv("SECRET_KEY", "") + placeholder_values = {"dev-secret-key-change-in-production", "your-secret-key-change-this", "your-secret-key-here"} + if not secret_key: return False - + if secret_key in placeholder_values: return False - + if len(secret_key) < 32: return False - + return True def validate_database_url() -> bool: """ Validate that DATABASE_URL is set and valid. - + Returns: True if valid, False otherwise """ - database_url = os.getenv('DATABASE_URL', '') - + database_url = os.getenv("DATABASE_URL", "") + if not database_url: # Check for PostgreSQL env vars - if all([ - os.getenv('POSTGRES_DB'), - os.getenv('POSTGRES_USER'), - os.getenv('POSTGRES_PASSWORD') - ]): + if all([os.getenv("POSTGRES_DB"), os.getenv("POSTGRES_USER"), os.getenv("POSTGRES_PASSWORD")]): return True return False - + # Basic validation - check for known database schemes - valid_schemes = ['postgresql', 'postgresql+psycopg2', 'sqlite'] + valid_schemes = ["postgresql", "postgresql+psycopg2", "sqlite"] if not any(database_url.startswith(scheme) for scheme in valid_schemes): return False - + return True def validate_production_config() -> Tuple[bool, List[str]]: """ Validate production configuration requirements. - + Returns: Tuple of (is_valid, issues) """ issues = [] - + # Check SECRET_KEY if not validate_secret_key(): - issues.append('SECRET_KEY must be set and at least 32 characters long') - + issues.append("SECRET_KEY must be set and at least 32 characters long") + # Check database if not validate_database_url(): - issues.append('DATABASE_URL or PostgreSQL environment variables must be set') - + issues.append("DATABASE_URL or PostgreSQL environment variables must be set") + # Check HTTPS settings in production - flask_env = os.getenv('FLASK_ENV', 'production') - if flask_env == 'production': - session_secure = os.getenv('SESSION_COOKIE_SECURE', 'false').lower() == 'true' + flask_env = os.getenv("FLASK_ENV", "production") + if flask_env == "production": + session_secure = os.getenv("SESSION_COOKIE_SECURE", "false").lower() == "true" if not session_secure: - issues.append('SESSION_COOKIE_SECURE should be true in production') - + issues.append("SESSION_COOKIE_SECURE should be true in production") + return len(issues) == 0, issues def validate_optional_env_vars() -> Dict[str, bool]: """ Validate optional environment variables and return their status. - + Returns: Dict mapping env var names to their validation status """ optional_vars = { - 'TZ': lambda v: bool(v), - 'CURRENCY': lambda v: bool(v), - 'OIDC_ISSUER': lambda v: bool(v) if os.getenv('AUTH_METHOD', '').lower() in ('oidc', 'both') else True, - 'OIDC_CLIENT_ID': lambda v: bool(v) if os.getenv('AUTH_METHOD', '').lower() in ('oidc', 'both') else True, - 'OIDC_CLIENT_SECRET': lambda v: bool(v) if os.getenv('AUTH_METHOD', '').lower() in ('oidc', 'both') else True, + "TZ": lambda v: bool(v), + "CURRENCY": lambda v: bool(v), + "OIDC_ISSUER": lambda v: bool(v) if os.getenv("AUTH_METHOD", "").lower() in ("oidc", "both") else True, + "OIDC_CLIENT_ID": lambda v: bool(v) if os.getenv("AUTH_METHOD", "").lower() in ("oidc", "both") else True, + "OIDC_CLIENT_SECRET": lambda v: bool(v) if os.getenv("AUTH_METHOD", "").lower() in ("oidc", "both") else True, } - + results = {} for var, validator in optional_vars.items(): - value = os.getenv(var, '') + value = os.getenv(var, "") results[var] = validator(value) - + return results def validate_all(raise_on_error: bool = False) -> Tuple[bool, Dict[str, any]]: """ Validate all environment configuration. - + Args: raise_on_error: If True, raise EnvValidationError on critical failures - + Returns: Tuple of (is_valid, validation_results) """ - results = { - 'required': {}, - 'optional': {}, - 'production': {}, - 'warnings': [] - } - + results = {"required": {}, "optional": {}, "production": {}, "warnings": []} + # Required vars (minimal set) required_vars = [] # Most vars have defaults, but SECRET_KEY is critical in production - is_production = os.getenv('FLASK_ENV', 'production') == 'production' - + is_production = os.getenv("FLASK_ENV", "production") == "production" + if is_production: - required_vars = ['SECRET_KEY'] - + required_vars = ["SECRET_KEY"] + is_valid, missing = validate_required_env_vars(required_vars, raise_on_error=False) - results['required'] = { - 'valid': is_valid, - 'missing': missing - } - + results["required"] = {"valid": is_valid, "missing": missing} + # Secret key validation secret_valid = validate_secret_key() if not secret_valid and is_production: - results['warnings'].append('SECRET_KEY is not secure for production') - + results["warnings"].append("SECRET_KEY is not secure for production") + # Database validation db_valid = validate_database_url() - results['required']['database_valid'] = db_valid - + results["required"]["database_valid"] = db_valid + # Production config validation prod_valid, prod_issues = validate_production_config() - results['production'] = { - 'valid': prod_valid, - 'issues': prod_issues - } - + results["production"] = {"valid": prod_valid, "issues": prod_issues} + # Optional vars - results['optional'] = validate_optional_env_vars() - + results["optional"] = validate_optional_env_vars() + # Overall validity overall_valid = is_valid and db_valid and (not is_production or prod_valid) - + if not overall_valid and raise_on_error: error_msg = "Environment validation failed:\n" if missing: @@ -202,6 +182,5 @@ def validate_all(raise_on_error: bool = False) -> Tuple[bool, Dict[str, any]]: if prod_issues: error_msg += f" Production issues: {', '.join(prod_issues)}\n" raise EnvValidationError(error_msg.strip()) - - return overall_valid, results + return overall_valid, results diff --git a/app/utils/error_handlers.py b/app/utils/error_handlers.py index 8a1e071c..6ba22be0 100644 --- a/app/utils/error_handlers.py +++ b/app/utils/error_handlers.py @@ -13,184 +13,162 @@ def register_error_handlers(app): """Register error handlers for the Flask app""" - + @app.errorhandler(400) def bad_request(error): """Handle 400 Bad Request errors""" - if request.is_json or request.path.startswith('/api/'): + if request.is_json or request.path.startswith("/api/"): return error_response( - message=str(error.description) if hasattr(error, 'description') else 'Bad request', - error_code='bad_request', - status_code=400 + message=str(error.description) if hasattr(error, "description") else "Bad request", + error_code="bad_request", + status_code=400, ) return error, 400 - + @app.errorhandler(401) def unauthorized(error): """Handle 401 Unauthorized errors""" - if request.is_json or request.path.startswith('/api/'): - return error_response( - message='Authentication required', - error_code='unauthorized', - status_code=401 - ) + if request.is_json or request.path.startswith("/api/"): + return error_response(message="Authentication required", error_code="unauthorized", status_code=401) return error, 401 - + @app.errorhandler(403) def forbidden(error): """Handle 403 Forbidden errors""" - if request.is_json or request.path.startswith('/api/'): - return error_response( - message='Insufficient permissions', - error_code='forbidden', - status_code=403 - ) + if request.is_json or request.path.startswith("/api/"): + return error_response(message="Insufficient permissions", error_code="forbidden", status_code=403) return error, 403 - + @app.errorhandler(404) def not_found(error): """Handle 404 Not Found errors""" - if request.is_json or request.path.startswith('/api/'): - return error_response( - message='Resource not found', - error_code='not_found', - status_code=404 - ) + if request.is_json or request.path.startswith("/api/"): + return error_response(message="Resource not found", error_code="not_found", status_code=404) return error, 404 - + @app.errorhandler(409) def conflict(error): """Handle 409 Conflict errors (e.g., duplicate entries)""" - if request.is_json or request.path.startswith('/api/'): + if request.is_json or request.path.startswith("/api/"): return error_response( - message=str(error.description) if hasattr(error, 'description') else 'Resource conflict', - error_code='conflict', - status_code=409 + message=str(error.description) if hasattr(error, "description") else "Resource conflict", + error_code="conflict", + status_code=409, ) return error, 409 - + @app.errorhandler(422) def unprocessable_entity(error): """Handle 422 Unprocessable Entity errors""" - if request.is_json or request.path.startswith('/api/'): - return error_response( - message='Unprocessable entity', - error_code='unprocessable_entity', - status_code=422 - ) + if request.is_json or request.path.startswith("/api/"): + return error_response(message="Unprocessable entity", error_code="unprocessable_entity", status_code=422) return error, 422 - + @app.errorhandler(ValidationError) def handle_marshmallow_validation_error(error): """Handle Marshmallow validation errors""" - if request.is_json or request.path.startswith('/api/'): + if request.is_json or request.path.startswith("/api/"): return handle_validation_error(error) # For HTML forms, flash the error from flask import flash - flash('Validation error: ' + str(error.messages), 'error') + + flash("Validation error: " + str(error.messages), "error") return error, 400 - + @app.errorhandler(IntegrityError) def handle_integrity_error(error): """Handle database integrity errors""" current_app.logger.error(f"Integrity error: {error}") - - if request.is_json or request.path.startswith('/api/'): + + if request.is_json or request.path.startswith("/api/"): # Try to extract meaningful error message - error_msg = 'Database integrity error' - if 'UNIQUE constraint' in str(error.orig): - error_msg = 'Duplicate entry - this record already exists' - elif 'FOREIGN KEY constraint' in str(error.orig): - error_msg = 'Referenced record does not exist' - - return error_response( - message=error_msg, - error_code='integrity_error', - status_code=409 - ) - + error_msg = "Database integrity error" + if "UNIQUE constraint" in str(error.orig): + error_msg = "Duplicate entry - this record already exists" + elif "FOREIGN KEY constraint" in str(error.orig): + error_msg = "Referenced record does not exist" + + return error_response(message=error_msg, error_code="integrity_error", status_code=409) + from flask import flash - flash('Database error occurred', 'error') + + flash("Database error occurred", "error") return error, 409 - + @app.errorhandler(SQLAlchemyError) def handle_sqlalchemy_error(error): """Handle SQLAlchemy errors""" current_app.logger.error(f"SQLAlchemy error: {error}") - - if request.is_json or request.path.startswith('/api/'): - return error_response( - message='Database error occurred', - error_code='database_error', - status_code=500 - ) - + + if request.is_json or request.path.startswith("/api/"): + return error_response(message="Database error occurred", error_code="database_error", status_code=500) + from flask import flash - flash('Database error occurred', 'error') + + flash("Database error occurred", "error") return error, 500 - + @app.errorhandler(HTTPException) def handle_http_exception(error): """Handle HTTP exceptions""" - if request.is_json or request.path.startswith('/api/'): + if request.is_json or request.path.startswith("/api/"): return error_response( - message=error.description or 'An error occurred', - error_code=error.code, - status_code=error.code + message=error.description or "An error occurred", error_code=error.code, status_code=error.code ) - return error - + from flask import render_template + + return render_template( + "errors/generic.html", + error=error, + error_info={"title": error.name, "message": error.description or "An error occurred"} + ), error.code + @app.errorhandler(Exception) def handle_generic_exception(error): """Handle all other exceptions""" current_app.logger.exception(f"Unhandled exception: {error}") - - if request.is_json or request.path.startswith('/api/'): + + if request.is_json or request.path.startswith("/api/"): # Don't expose internal error details in production - if current_app.config.get('FLASK_DEBUG'): + if current_app.config.get("FLASK_DEBUG"): return error_response( message=str(error), - error_code='internal_error', + error_code="internal_error", status_code=500, - details={'type': type(error).__name__} + details={"type": type(error).__name__}, ) else: return error_response( - message='An internal error occurred', - error_code='internal_error', - status_code=500 + message="An internal error occurred", error_code="internal_error", status_code=500 ) - - from flask import flash - flash('An error occurred. Please try again.', 'error') - return error, 500 + + from flask import render_template, flash + + flash("An error occurred. Please try again.", "error") + return render_template( + "errors/500.html", + error_info={"title": "Server Error", "message": "Something went wrong on our end. Please try again later."} + ), 500 def create_error_response( - message: str, - error_code: str = 'error', - status_code: int = 400, - details: Optional[Dict[str, Any]] = None + message: str, error_code: str = "error", status_code: int = 400, details: Optional[Dict[str, Any]] = None ) -> tuple: """ Create a standardized error response. - + Args: message: Error message error_code: Error code status_code: HTTP status code details: Optional additional details - + Returns: Tuple of (response_dict, status_code) """ - response = { - 'success': False, - 'error': error_code, - 'message': message - } - + response = {"success": False, "error": error_code, "message": message} + if details: - response['details'] = details - + response["details"] = details + return response, status_code diff --git a/app/utils/event_bus.py b/app/utils/event_bus.py index 3b89dbb2..f1ecbea5 100644 --- a/app/utils/event_bus.py +++ b/app/utils/event_bus.py @@ -11,14 +11,14 @@ class EventBus: """Simple event bus for domain events""" - + def __init__(self): self._handlers: Dict[str, List[Callable]] = {} - + def subscribe(self, event_type: str, handler: Callable) -> None: """ Subscribe a handler to an event type. - + Args: event_type: Event type (e.g., 'time_entry.created') handler: Function to call when event is emitted @@ -26,7 +26,7 @@ def subscribe(self, event_type: str, handler: Callable) -> None: if event_type not in self._handlers: self._handlers[event_type] = [] self._handlers[event_type].append(handler) - + def unsubscribe(self, event_type: str, handler: Callable) -> None: """Unsubscribe a handler from an event type""" if event_type in self._handlers: @@ -34,11 +34,11 @@ def unsubscribe(self, event_type: str, handler: Callable) -> None: self._handlers[event_type].remove(handler) except ValueError: pass - + def emit(self, event_type: str, data: Dict[str, Any]) -> None: """ Emit an event to all subscribed handlers. - + Args: event_type: Event type data: Event data @@ -48,11 +48,8 @@ def emit(self, event_type: str, data: Dict[str, Any]) -> None: try: handler(event_type, data) except Exception as e: - current_app.logger.error( - f"Error in event handler for {event_type}: {e}", - exc_info=True - ) - + current_app.logger.error(f"Error in event handler for {event_type}: {e}", exc_info=True) + def clear(self) -> None: """Clear all event handlers""" self._handlers.clear() @@ -70,7 +67,7 @@ def get_event_bus() -> EventBus: def emit_event(event_type: str, data: Dict[str, Any]) -> None: """ Emit an event using the global event bus. - + Args: event_type: Event type data: Event data @@ -81,15 +78,17 @@ def emit_event(event_type: str, data: Dict[str, Any]) -> None: def subscribe_to_event(event_type: str): """ Decorator to subscribe a function to an event type. - + Usage: @subscribe_to_event('time_entry.created') def handle_time_entry_created(event_type, data): # Handle event """ + def decorator(func: Callable) -> Callable: _event_bus.subscribe(event_type, func) return func + return decorator @@ -99,6 +98,7 @@ def handle_time_entry_created(event_type: str, data: Dict[str, Any]) -> None: """Handle time entry created event""" try: from app.utils.webhook_dispatcher import dispatch_webhook + dispatch_webhook(event_type, data) except Exception as e: current_app.logger.error(f"Failed to dispatch webhook for {event_type}: {e}") @@ -109,6 +109,7 @@ def handle_project_created(event_type: str, data: Dict[str, Any]) -> None: """Handle project created event""" try: from app.utils.webhook_dispatcher import dispatch_webhook + dispatch_webhook(event_type, data) except Exception as e: current_app.logger.error(f"Failed to dispatch webhook for {event_type}: {e}") @@ -119,7 +120,7 @@ def handle_invoice_created(event_type: str, data: Dict[str, Any]) -> None: """Handle invoice created event""" try: from app.utils.webhook_dispatcher import dispatch_webhook + dispatch_webhook(event_type, data) except Exception as e: current_app.logger.error(f"Failed to dispatch webhook for {event_type}: {e}") - diff --git a/app/utils/excel_export.py b/app/utils/excel_export.py index 6047fe51..ebbd41c7 100644 --- a/app/utils/excel_export.py +++ b/app/utils/excel_export.py @@ -8,13 +8,13 @@ from app.utils.timezone import convert_app_datetime_to_user -def create_time_entries_excel(entries, filename_prefix='timetracker_export'): +def create_time_entries_excel(entries, filename_prefix="timetracker_export"): """Create Excel file from time entries - + Args: entries: List of TimeEntry objects filename_prefix: Prefix for the filename - + Returns: tuple: (BytesIO object with Excel file, filename) """ @@ -22,25 +22,33 @@ def create_time_entries_excel(entries, filename_prefix='timetracker_export'): wb = Workbook() ws = wb.active ws.title = "Time Entries" - + # Define styles header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center") border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') + left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin") ) - + # Headers headers = [ - 'ID', 'User', 'Project', 'Client', 'Task', 'Start Time', 'End Time', - 'Duration (hours)', 'Duration (formatted)', 'Notes', 'Tags', - 'Source', 'Billable', 'Created At' + "ID", + "User", + "Project", + "Client", + "Task", + "Start Time", + "End Time", + "Duration (hours)", + "Duration (formatted)", + "Notes", + "Tags", + "Source", + "Billable", + "Created At", ] - + # Write headers with styling for col_num, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_num, value=header) @@ -48,34 +56,34 @@ def create_time_entries_excel(entries, filename_prefix='timetracker_export'): cell.fill = header_fill cell.alignment = header_alignment cell.border = border - + # Write data for row_num, entry in enumerate(entries, 2): data = [ entry.id, - entry.user.display_name if entry.user else 'Unknown', - entry.project.name if entry.project else 'N/A', - entry.project.client if (entry.project and entry.project.client) else 'N/A', - entry.task.name if entry.task else 'N/A', - entry.start_time.isoformat() if entry.start_time else '', - entry.end_time.isoformat() if entry.end_time else '', + entry.user.display_name if entry.user else "Unknown", + entry.project.name if entry.project else "N/A", + entry.project.client if (entry.project and entry.project.client) else "N/A", + entry.task.name if entry.task else "N/A", + entry.start_time.isoformat() if entry.start_time else "", + entry.end_time.isoformat() if entry.end_time else "", entry.duration_hours if entry.end_time else 0, - entry.duration_formatted if entry.end_time else 'In Progress', - entry.notes or '', - entry.tags or '', - entry.source or 'manual', - 'Yes' if entry.billable else 'No', - entry.created_at.isoformat() if entry.created_at else '' + entry.duration_formatted if entry.end_time else "In Progress", + entry.notes or "", + entry.tags or "", + entry.source or "manual", + "Yes" if entry.billable else "No", + entry.created_at.isoformat() if entry.created_at else "", ] - + for col_num, value in enumerate(data, 1): cell = ws.cell(row=row_num, column=col_num, value=value) cell.border = border - + # Format duration column as number if col_num == 8 and isinstance(value, (int, float)): - cell.number_format = '0.00' - + cell.number_format = "0.00" + # Auto-adjust column widths for col_idx, col in enumerate(ws.columns, 1): max_length = 0 @@ -89,101 +97,104 @@ def create_time_entries_excel(entries, filename_prefix='timetracker_export'): pass adjusted_width = min(max_length + 2, 50) # Cap at 50 ws.column_dimensions[column].width = adjusted_width - + # Add summary at the bottom last_row = len(entries) + 2 ws.cell(row=last_row + 1, column=1, value="Summary") ws.cell(row=last_row + 1, column=1).font = Font(bold=True) - + total_hours = sum(e.duration_hours for e in entries if e.end_time) billable_hours = sum(e.duration_hours for e in entries if e.end_time and e.billable) - + ws.cell(row=last_row + 2, column=1, value="Total Hours:") - ws.cell(row=last_row + 2, column=2, value=total_hours).number_format = '0.00' + ws.cell(row=last_row + 2, column=2, value=total_hours).number_format = "0.00" ws.cell(row=last_row + 3, column=1, value="Billable Hours:") - ws.cell(row=last_row + 3, column=2, value=billable_hours).number_format = '0.00' + ws.cell(row=last_row + 3, column=2, value=billable_hours).number_format = "0.00" ws.cell(row=last_row + 4, column=1, value="Total Entries:") ws.cell(row=last_row + 4, column=2, value=len(entries)) - + # Save to BytesIO output = io.BytesIO() wb.save(output) output.seek(0) - + # Generate filename - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'{filename_prefix}_{timestamp}.xlsx' - + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{filename_prefix}_{timestamp}.xlsx" + return output, filename def create_project_report_excel(projects_data, start_date, end_date): """Create Excel file for project report - + Args: projects_data: List of project dictionaries with hours and costs start_date: Report start date end_date: Report end date - + Returns: tuple: (BytesIO object with Excel file, filename) """ wb = Workbook() ws = wb.active ws.title = "Project Report" - + # Styles header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') + left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin") ) - + # Add report header - ws.merge_cells('A1:H1') - title_cell = ws['A1'] + ws.merge_cells("A1:H1") + title_cell = ws["A1"] title_cell.value = f"Project Report: {start_date} to {end_date}" title_cell.font = Font(bold=True, size=14) title_cell.alignment = Alignment(horizontal="center") - + # Column headers headers = [ - 'Project', 'Client', 'Total Hours', 'Billable Hours', - 'Hourly Rate', 'Billable Amount', 'Total Costs', 'Total Value' + "Project", + "Client", + "Total Hours", + "Billable Hours", + "Hourly Rate", + "Billable Amount", + "Total Costs", + "Total Value", ] - + for col_num, header in enumerate(headers, 1): cell = ws.cell(row=3, column=col_num, value=header) cell.font = header_font cell.fill = header_fill cell.border = border - + # Write project data for row_num, project in enumerate(projects_data, 4): data = [ - project.get('name', ''), - project.get('client', ''), - project.get('total_hours', 0), - project.get('billable_hours', 0), - project.get('hourly_rate', 0), - project.get('billable_amount', 0), - project.get('total_costs', 0), - project.get('total_value', 0), + project.get("name", ""), + project.get("client", ""), + project.get("total_hours", 0), + project.get("billable_hours", 0), + project.get("hourly_rate", 0), + project.get("billable_amount", 0), + project.get("total_costs", 0), + project.get("total_value", 0), ] - + for col_num, value in enumerate(data, 1): cell = ws.cell(row=row_num, column=col_num, value=value) cell.border = border - + # Format numbers if col_num in [3, 4]: # Hours - cell.number_format = '0.00' + cell.number_format = "0.00" elif col_num in [5, 6, 7, 8]: # Money - cell.number_format = '#,##0.00' - + cell.number_format = "#,##0.00" + # Auto-adjust columns for col_idx, col in enumerate(ws.columns, 1): max_length = 0 @@ -198,140 +209,150 @@ def create_project_report_excel(projects_data, start_date, end_date): pass adjusted_width = min(max_length + 2, 40) ws.column_dimensions[column].width = adjusted_width - + # Add totals last_row = len(projects_data) + 4 ws.cell(row=last_row + 1, column=1, value="TOTALS").font = Font(bold=True) - - total_hours = sum(p.get('total_hours', 0) for p in projects_data) - total_billable_hours = sum(p.get('billable_hours', 0) for p in projects_data) - total_amount = sum(p.get('billable_amount', 0) for p in projects_data) - total_costs = sum(p.get('total_costs', 0) for p in projects_data) - total_value = sum(p.get('total_value', 0) for p in projects_data) - - ws.cell(row=last_row + 1, column=3, value=total_hours).number_format = '0.00' - ws.cell(row=last_row + 1, column=4, value=total_billable_hours).number_format = '0.00' - ws.cell(row=last_row + 1, column=6, value=total_amount).number_format = '#,##0.00' - ws.cell(row=last_row + 1, column=7, value=total_costs).number_format = '#,##0.00' - ws.cell(row=last_row + 1, column=8, value=total_value).number_format = '#,##0.00' - + + total_hours = sum(p.get("total_hours", 0) for p in projects_data) + total_billable_hours = sum(p.get("billable_hours", 0) for p in projects_data) + total_amount = sum(p.get("billable_amount", 0) for p in projects_data) + total_costs = sum(p.get("total_costs", 0) for p in projects_data) + total_value = sum(p.get("total_value", 0) for p in projects_data) + + ws.cell(row=last_row + 1, column=3, value=total_hours).number_format = "0.00" + ws.cell(row=last_row + 1, column=4, value=total_billable_hours).number_format = "0.00" + ws.cell(row=last_row + 1, column=6, value=total_amount).number_format = "#,##0.00" + ws.cell(row=last_row + 1, column=7, value=total_costs).number_format = "#,##0.00" + ws.cell(row=last_row + 1, column=8, value=total_value).number_format = "#,##0.00" + # Save to BytesIO output = io.BytesIO() wb.save(output) output.seek(0) - - filename = f'project_report_{start_date}_to_{end_date}.xlsx' + + filename = f"project_report_{start_date}_to_{end_date}.xlsx" return output, filename def create_invoice_excel(invoice, items): """Create Excel file for a single invoice - + Args: invoice: Invoice object items: List of InvoiceItem objects - + Returns: tuple: (BytesIO object with Excel file, filename) """ wb = Workbook() ws = wb.active ws.title = "Invoice" - + # Invoice header - ws.merge_cells('A1:D1') - ws['A1'] = f"INVOICE {invoice.invoice_number}" - ws['A1'].font = Font(bold=True, size=16) - ws['A1'].alignment = Alignment(horizontal="center") - + ws.merge_cells("A1:D1") + ws["A1"] = f"INVOICE {invoice.invoice_number}" + ws["A1"].font = Font(bold=True, size=16) + ws["A1"].alignment = Alignment(horizontal="center") + # Invoice details - ws['A3'] = "Client:" - ws['B3'] = invoice.client_name - ws['A4'] = "Issue Date:" - ws['B4'] = invoice.issue_date.strftime('%Y-%m-%d') - ws['A5'] = "Due Date:" - ws['B5'] = invoice.due_date.strftime('%Y-%m-%d') - ws['A6'] = "Status:" - ws['B6'] = invoice.status.upper() - + ws["A3"] = "Client:" + ws["B3"] = invoice.client_name + ws["A4"] = "Issue Date:" + ws["B4"] = invoice.issue_date.strftime("%Y-%m-%d") + ws["A5"] = "Due Date:" + ws["B5"] = invoice.due_date.strftime("%Y-%m-%d") + ws["A6"] = "Status:" + ws["B6"] = invoice.status.upper() + # Items header - headers = ['Description', 'Quantity', 'Unit Price', 'Amount'] + headers = ["Description", "Quantity", "Unit Price", "Amount"] for col_num, header in enumerate(headers, 1): cell = ws.cell(row=8, column=col_num, value=header) cell.font = Font(bold=True) cell.fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid") - + # Items row = 9 for item in items: ws.cell(row=row, column=1, value=item.description) - ws.cell(row=row, column=2, value=item.quantity).number_format = '0.00' - ws.cell(row=row, column=3, value=float(item.unit_price)).number_format = '#,##0.00' - ws.cell(row=row, column=4, value=float(item.amount)).number_format = '#,##0.00' + ws.cell(row=row, column=2, value=item.quantity).number_format = "0.00" + ws.cell(row=row, column=3, value=float(item.unit_price)).number_format = "#,##0.00" + ws.cell(row=row, column=4, value=float(item.amount)).number_format = "#,##0.00" row += 1 - + # Totals row += 1 ws.cell(row=row, column=3, value="Subtotal:").font = Font(bold=True) - ws.cell(row=row, column=4, value=float(invoice.subtotal)).number_format = '#,##0.00' - + ws.cell(row=row, column=4, value=float(invoice.subtotal)).number_format = "#,##0.00" + row += 1 ws.cell(row=row, column=3, value=f"Tax ({invoice.tax_rate}%):").font = Font(bold=True) - ws.cell(row=row, column=4, value=float(invoice.tax_amount)).number_format = '#,##0.00' - + ws.cell(row=row, column=4, value=float(invoice.tax_amount)).number_format = "#,##0.00" + row += 1 ws.cell(row=row, column=3, value="TOTAL:").font = Font(bold=True, size=12) total_cell = ws.cell(row=row, column=4, value=float(invoice.total_amount)) - total_cell.number_format = '#,##0.00' + total_cell.number_format = "#,##0.00" total_cell.font = Font(bold=True, size=12) total_cell.fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid") - + # Adjust columns - ws.column_dimensions['A'].width = 40 - ws.column_dimensions['B'].width = 15 - ws.column_dimensions['C'].width = 15 - ws.column_dimensions['D'].width = 15 - + ws.column_dimensions["A"].width = 40 + ws.column_dimensions["B"].width = 15 + ws.column_dimensions["C"].width = 15 + ws.column_dimensions["D"].width = 15 + # Save output = io.BytesIO() wb.save(output) output.seek(0) - - filename = f'invoice_{invoice.invoice_number}.xlsx' + + filename = f"invoice_{invoice.invoice_number}.xlsx" return output, filename def create_invoices_list_excel(invoices): """Create Excel file for invoice list - + Args: invoices: List of Invoice objects - + Returns: tuple: (BytesIO object with Excel file, filename) """ wb = Workbook() ws = wb.active ws.title = "Invoices" - + # Define styles header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center") border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') + left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin") ) - + # Headers headers = [ - 'Invoice Number', 'Client Name', 'Project', 'Issue Date', 'Due Date', - 'Status', 'Payment Status', 'Subtotal', 'Tax Rate (%)', 'Tax Amount', - 'Total Amount', 'Amount Paid', 'Outstanding', 'Currency', 'Created By', 'Created At' + "Invoice Number", + "Client Name", + "Project", + "Issue Date", + "Due Date", + "Status", + "Payment Status", + "Subtotal", + "Tax Rate (%)", + "Tax Amount", + "Total Amount", + "Amount Paid", + "Outstanding", + "Currency", + "Created By", + "Created At", ] - + # Write headers with styling for col_num, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_num, value=header) @@ -339,40 +360,40 @@ def create_invoices_list_excel(invoices): cell.fill = header_fill cell.alignment = header_alignment cell.border = border - + # Write data for row_num, invoice in enumerate(invoices, 2): data = [ invoice.invoice_number, - invoice.client_name or 'N/A', - invoice.project.name if invoice.project else 'N/A', - invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else '', - invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else '', - invoice.status or 'draft', - invoice.payment_status or 'unpaid', + invoice.client_name or "N/A", + invoice.project.name if invoice.project else "N/A", + invoice.issue_date.strftime("%Y-%m-%d") if invoice.issue_date else "", + invoice.due_date.strftime("%Y-%m-%d") if invoice.due_date else "", + invoice.status or "draft", + invoice.payment_status or "unpaid", float(invoice.subtotal or 0), float(invoice.tax_rate or 0), float(invoice.tax_amount or 0), float(invoice.total_amount or 0), float(invoice.amount_paid or 0), float(invoice.outstanding_amount or 0), - invoice.currency_code or 'USD', - invoice.creator.display_name if invoice.creator else 'Unknown', - (convert_app_datetime_to_user(invoice.created_at).strftime('%Y-%m-%d %H:%M') if invoice.created_at else '') + invoice.currency_code or "USD", + invoice.creator.display_name if invoice.creator else "Unknown", + (convert_app_datetime_to_user(invoice.created_at).strftime("%Y-%m-%d %H:%M") if invoice.created_at else ""), ] - + for col_num, value in enumerate(data, 1): cell = ws.cell(row=row_num, column=col_num, value=value) cell.border = border - + # Format number columns if col_num in [8, 10, 11, 12, 13]: # Money columns if isinstance(value, (int, float)): - cell.number_format = '#,##0.00' + cell.number_format = "#,##0.00" elif col_num == 9: # Tax rate percentage if isinstance(value, (int, float)): - cell.number_format = '0.00' - + cell.number_format = "0.00" + # Auto-adjust column widths for col_idx, col in enumerate(ws.columns, 1): max_length = 0 @@ -386,68 +407,77 @@ def create_invoices_list_excel(invoices): pass adjusted_width = min(max_length + 2, 50) # Cap at 50 ws.column_dimensions[column].width = adjusted_width - + # Add summary at the bottom last_row = len(invoices) + 2 ws.cell(row=last_row + 1, column=1, value="Summary") ws.cell(row=last_row + 1, column=1).font = Font(bold=True) - + total_invoiced = sum(float(inv.total_amount or 0) for inv in invoices) total_paid = sum(float(inv.amount_paid or 0) for inv in invoices) total_outstanding = sum(float(inv.outstanding_amount or 0) for inv in invoices) - + ws.cell(row=last_row + 2, column=1, value="Total Invoiced:") - ws.cell(row=last_row + 2, column=2, value=total_invoiced).number_format = '#,##0.00' + ws.cell(row=last_row + 2, column=2, value=total_invoiced).number_format = "#,##0.00" ws.cell(row=last_row + 3, column=1, value="Total Paid:") - ws.cell(row=last_row + 3, column=2, value=total_paid).number_format = '#,##0.00' + ws.cell(row=last_row + 3, column=2, value=total_paid).number_format = "#,##0.00" ws.cell(row=last_row + 4, column=1, value="Total Outstanding:") - ws.cell(row=last_row + 4, column=2, value=total_outstanding).number_format = '#,##0.00' + ws.cell(row=last_row + 4, column=2, value=total_outstanding).number_format = "#,##0.00" ws.cell(row=last_row + 5, column=1, value="Total Invoices:") ws.cell(row=last_row + 5, column=2, value=len(invoices)) - + # Save to BytesIO output = io.BytesIO() wb.save(output) output.seek(0) - + # Generate filename - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'invoices_list_{timestamp}.xlsx' - + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"invoices_list_{timestamp}.xlsx" + return output, filename def create_payments_list_excel(payments): """Create Excel file for payment list - + Args: payments: List of Payment objects - + Returns: tuple: (BytesIO object with Excel file, filename) """ wb = Workbook() ws = wb.active ws.title = "Payments" - + # Define styles header_font = Font(bold=True, color="FFFFFF") header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_alignment = Alignment(horizontal="center", vertical="center") border = Border( - left=Side(style='thin'), - right=Side(style='thin'), - top=Side(style='thin'), - bottom=Side(style='thin') + left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin") ) - + # Headers headers = [ - 'Payment ID', 'Invoice Number', 'Client Name', 'Amount', 'Currency', - 'Gateway Fee', 'Net Amount', 'Payment Date', 'Method', 'Reference', - 'Status', 'Received By', 'Gateway Transaction ID', 'Notes', 'Created At' + "Payment ID", + "Invoice Number", + "Client Name", + "Amount", + "Currency", + "Gateway Fee", + "Net Amount", + "Payment Date", + "Method", + "Reference", + "Status", + "Received By", + "Gateway Transaction ID", + "Notes", + "Created At", ] - + # Write headers with styling for col_num, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_num, value=header) @@ -455,36 +485,36 @@ def create_payments_list_excel(payments): cell.fill = header_fill cell.alignment = header_alignment cell.border = border - + # Write data for row_num, payment in enumerate(payments, 2): data = [ payment.id, - payment.invoice.invoice_number if payment.invoice else 'N/A', - payment.invoice.client_name if payment.invoice else 'N/A', + payment.invoice.invoice_number if payment.invoice else "N/A", + payment.invoice.client_name if payment.invoice else "N/A", float(payment.amount or 0), - payment.currency or 'EUR', + payment.currency or "EUR", float(payment.gateway_fee or 0), float(payment.net_amount or payment.amount or 0), - payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else '', - payment.method or 'N/A', - payment.reference or '', - payment.status or 'completed', - payment.receiver.display_name if payment.receiver else 'N/A', - payment.gateway_transaction_id or '', - payment.notes or '', - (convert_app_datetime_to_user(payment.created_at).strftime('%Y-%m-%d %H:%M') if payment.created_at else '') + payment.payment_date.strftime("%Y-%m-%d") if payment.payment_date else "", + payment.method or "N/A", + payment.reference or "", + payment.status or "completed", + payment.receiver.display_name if payment.receiver else "N/A", + payment.gateway_transaction_id or "", + payment.notes or "", + (convert_app_datetime_to_user(payment.created_at).strftime("%Y-%m-%d %H:%M") if payment.created_at else ""), ] - + for col_num, value in enumerate(data, 1): cell = ws.cell(row=row_num, column=col_num, value=value) cell.border = border - + # Format number columns if col_num in [4, 6, 7]: # Money columns if isinstance(value, (int, float)): - cell.number_format = '#,##0.00' - + cell.number_format = "#,##0.00" + # Auto-adjust column widths for col_idx, col in enumerate(ws.columns, 1): max_length = 0 @@ -498,36 +528,35 @@ def create_payments_list_excel(payments): pass adjusted_width = min(max_length + 2, 50) # Cap at 50 ws.column_dimensions[column].width = adjusted_width - + # Add summary at the bottom last_row = len(payments) + 2 ws.cell(row=last_row + 1, column=1, value="Summary") ws.cell(row=last_row + 1, column=1).font = Font(bold=True) - + total_amount = sum(float(p.amount or 0) for p in payments) total_fees = sum(float(p.gateway_fee or 0) for p in payments if p.gateway_fee) total_net = sum(float(p.net_amount or p.amount or 0) for p in payments) - completed_count = sum(1 for p in payments if p.status == 'completed') - + completed_count = sum(1 for p in payments if p.status == "completed") + ws.cell(row=last_row + 2, column=1, value="Total Amount:") - ws.cell(row=last_row + 2, column=2, value=total_amount).number_format = '#,##0.00' + ws.cell(row=last_row + 2, column=2, value=total_amount).number_format = "#,##0.00" ws.cell(row=last_row + 3, column=1, value="Total Gateway Fees:") - ws.cell(row=last_row + 3, column=2, value=total_fees).number_format = '#,##0.00' + ws.cell(row=last_row + 3, column=2, value=total_fees).number_format = "#,##0.00" ws.cell(row=last_row + 4, column=1, value="Total Net Amount:") - ws.cell(row=last_row + 4, column=2, value=total_net).number_format = '#,##0.00' + ws.cell(row=last_row + 4, column=2, value=total_net).number_format = "#,##0.00" ws.cell(row=last_row + 5, column=1, value="Total Payments:") ws.cell(row=last_row + 5, column=2, value=len(payments)) ws.cell(row=last_row + 6, column=1, value="Completed Payments:") ws.cell(row=last_row + 6, column=2, value=completed_count) - + # Save to BytesIO output = io.BytesIO() wb.save(output) output.seek(0) - + # Generate filename - timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') - filename = f'payments_list_{timestamp}.xlsx' - - return output, filename + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"payments_list_{timestamp}.xlsx" + return output, filename diff --git a/app/utils/file_upload.py b/app/utils/file_upload.py index f014a823..69eab1b7 100644 --- a/app/utils/file_upload.py +++ b/app/utils/file_upload.py @@ -7,65 +7,56 @@ from flask import current_app import os from pathlib import Path -from app.constants import ( - MAX_FILE_SIZE, - ALLOWED_IMAGE_EXTENSIONS, - ALLOWED_DOCUMENT_EXTENSIONS -) +from app.constants import MAX_FILE_SIZE, ALLOWED_IMAGE_EXTENSIONS, ALLOWED_DOCUMENT_EXTENSIONS def validate_file_upload( - file, - allowed_extensions: Optional[set] = None, - max_size: int = MAX_FILE_SIZE + file, allowed_extensions: Optional[set] = None, max_size: int = MAX_FILE_SIZE ) -> Tuple[bool, Optional[str]]: """ Validate a file upload. - + Args: file: File object from request allowed_extensions: Set of allowed extensions (defaults to all) max_size: Maximum file size in bytes - + Returns: tuple of (is_valid, error_message) """ if not file or not file.filename: return False, "No file provided" - + # Check file size file.seek(0, os.SEEK_END) file_size = file.tell() file.seek(0) - + if file_size > max_size: return False, f"File size exceeds maximum of {max_size / (1024*1024):.1f}MB" - + # Check extension if allowed_extensions: filename = secure_filename(file.filename) ext = Path(filename).suffix.lower() if ext not in allowed_extensions: return False, f"File type not allowed. Allowed types: {', '.join(allowed_extensions)}" - + return True, None def save_uploaded_file( - file, - upload_folder: str, - subfolder: Optional[str] = None, - prefix: Optional[str] = None + file, upload_folder: str, subfolder: Optional[str] = None, prefix: Optional[str] = None ) -> Optional[str]: """ Save an uploaded file securely. - + Args: file: File object from request upload_folder: Base upload folder subfolder: Optional subfolder (e.g., 'receipts', 'avatars') prefix: Optional filename prefix - + Returns: Saved file path or None on error """ @@ -74,20 +65,20 @@ def save_uploaded_file( filename = secure_filename(file.filename) if not filename: return None - + # Add prefix if provided if prefix: name, ext = os.path.splitext(filename) filename = f"{prefix}_{name}{ext}" - + # Create directory structure if subfolder: upload_path = os.path.join(upload_folder, subfolder) else: upload_path = upload_folder - + os.makedirs(upload_path, exist_ok=True) - + # Ensure unique filename filepath = os.path.join(upload_path, filename) counter = 1 @@ -95,15 +86,15 @@ def save_uploaded_file( name, ext = os.path.splitext(filename) filepath = os.path.join(upload_path, f"{name}_{counter}{ext}") counter += 1 - + # Save file file.save(filepath) - + # Return relative path if subfolder: return os.path.join(subfolder, os.path.basename(filepath)) return os.path.basename(filepath) - + except Exception as e: current_app.logger.error(f"Error saving uploaded file: {e}") return None @@ -112,11 +103,11 @@ def save_uploaded_file( def delete_uploaded_file(filepath: str, upload_folder: str) -> bool: """ Delete an uploaded file. - + Args: filepath: Relative file path upload_folder: Base upload folder - + Returns: True if deleted, False otherwise """ @@ -134,11 +125,11 @@ def delete_uploaded_file(filepath: str, upload_folder: str) -> bool: def get_file_info(filepath: str, upload_folder: str) -> Optional[dict]: """ Get information about an uploaded file. - + Args: filepath: Relative file path upload_folder: Base upload folder - + Returns: dict with file info or None """ @@ -146,15 +137,14 @@ def get_file_info(filepath: str, upload_folder: str) -> Optional[dict]: full_path = os.path.join(upload_folder, filepath) if not os.path.exists(full_path): return None - + stat = os.stat(full_path) return { - 'path': filepath, - 'size': stat.st_size, - 'modified': stat.st_mtime, - 'extension': Path(filepath).suffix.lower() + "path": filepath, + "size": stat.st_size, + "modified": stat.st_mtime, + "extension": Path(filepath).suffix.lower(), } except Exception as e: current_app.logger.error(f"Error getting file info: {e}") return None - diff --git a/app/utils/i18n.py b/app/utils/i18n.py index 880bfa61..e5272ead 100644 --- a/app/utils/i18n.py +++ b/app/utils/i18n.py @@ -22,10 +22,10 @@ def compile_po_to_mo(po_path: str, mo_path: str) -> bool: from babel.messages.pofile import read_po from babel.messages.mofile import write_mo - with open(po_path, 'r', encoding='utf-8') as po_file: + with open(po_path, "r", encoding="utf-8") as po_file: catalog = read_po(po_file) os.makedirs(os.path.dirname(mo_path), exist_ok=True) - with open(mo_path, 'wb') as mo_file: + with open(mo_path, "wb") as mo_file: write_mo(mo_file, catalog) return True except ImportError: @@ -35,7 +35,8 @@ def compile_po_to_mo(po_path: str, mo_path: str) -> bool: except Exception as e: # Log the actual error for debugging import logging - logger = logging.getLogger('timetracker') + + logger = logging.getLogger("timetracker") logger.warning(f"Error compiling {po_path}: {e}", exc_info=True) return False @@ -54,14 +55,15 @@ def ensure_translations_compiled(translations_dir: str) -> None: if not os.path.isdir(translations_dir): return for lang in os.listdir(translations_dir): - lang_dir = os.path.join(translations_dir, lang, 'LC_MESSAGES') + lang_dir = os.path.join(translations_dir, lang, "LC_MESSAGES") if not os.path.isdir(lang_dir): continue - po_path = os.path.join(lang_dir, 'messages.po') - mo_path = os.path.join(lang_dir, 'messages.mo') + po_path = os.path.join(lang_dir, "messages.po") + mo_path = os.path.join(lang_dir, "messages.mo") if os.path.exists(po_path) and _needs_compile(po_path, mo_path): import logging - logger = logging.getLogger('timetracker') + + logger = logging.getLogger("timetracker") logger.info(f"Compiling translations for {lang}...") success = compile_po_to_mo(po_path, mo_path) if success: @@ -71,12 +73,13 @@ def ensure_translations_compiled(translations_dir: str) -> None: logger.warning(f"Compilation reported success but {mo_path} not found") else: # Log failure - this is important for debugging - logger.warning(f"Failed to compile translations for {lang} - translations may not work. Check if Babel is installed.") + logger.warning( + f"Failed to compile translations for {lang} - translations may not work. Check if Babel is installed." + ) except Exception as e: # Non-fatal; i18n will fall back to msgid if mo missing import logging - logger = logging.getLogger('timetracker') + + logger = logging.getLogger("timetracker") logger.warning(f"Error compiling translations: {e}") pass - - diff --git a/app/utils/i18n_helpers.py b/app/utils/i18n_helpers.py index 961f4283..065afafe 100644 --- a/app/utils/i18n_helpers.py +++ b/app/utils/i18n_helpers.py @@ -12,77 +12,59 @@ def get_task_status_display(status): """Get translated display name for task status""" status_map = { - 'todo': _('To Do'), - 'in_progress': _('In Progress'), - 'review': _('Review'), - 'done': _('Done'), - 'cancelled': _('Cancelled') + "todo": _("To Do"), + "in_progress": _("In Progress"), + "review": _("Review"), + "done": _("Done"), + "cancelled": _("Cancelled"), } - return status_map.get(status, status.replace('_', ' ').title()) + return status_map.get(status, status.replace("_", " ").title()) def get_task_statuses(): """Get list of all task statuses with translations""" return [ - ('todo', _('To Do')), - ('in_progress', _('In Progress')), - ('review', _('Review')), - ('done', _('Done')), - ('cancelled', _('Cancelled')) + ("todo", _("To Do")), + ("in_progress", _("In Progress")), + ("review", _("Review")), + ("done", _("Done")), + ("cancelled", _("Cancelled")), ] # Task Priority Translations def get_task_priority_display(priority): """Get translated display name for task priority""" - priority_map = { - 'low': _('Low'), - 'medium': _('Medium'), - 'high': _('High'), - 'urgent': _('Urgent') - } + priority_map = {"low": _("Low"), "medium": _("Medium"), "high": _("High"), "urgent": _("Urgent")} return priority_map.get(priority, priority.capitalize()) def get_task_priorities(): """Get list of all task priorities with translations""" - return [ - ('low', _('Low')), - ('medium', _('Medium')), - ('high', _('High')), - ('urgent', _('Urgent')) - ] + return [("low", _("Low")), ("medium", _("Medium")), ("high", _("High")), ("urgent", _("Urgent"))] # Project Status Translations def get_project_status_display(status): """Get translated display name for project status""" - status_map = { - 'active': _('Active'), - 'inactive': _('Inactive'), - 'archived': _('Archived') - } + status_map = {"active": _("Active"), "inactive": _("Inactive"), "archived": _("Archived")} return status_map.get(status, status.capitalize()) def get_project_statuses(): """Get list of all project statuses with translations""" - return [ - ('active', _('Active')), - ('inactive', _('Inactive')), - ('archived', _('Archived')) - ] + return [("active", _("Active")), ("inactive", _("Inactive")), ("archived", _("Archived"))] # Invoice Status Translations def get_invoice_status_display(status): """Get translated display name for invoice status""" status_map = { - 'draft': _('Draft'), - 'sent': _('Sent'), - 'paid': _('Paid'), - 'overdue': _('Overdue'), - 'cancelled': _('Cancelled') + "draft": _("Draft"), + "sent": _("Sent"), + "paid": _("Paid"), + "overdue": _("Overdue"), + "cancelled": _("Cancelled"), } return status_map.get(status, status.capitalize()) @@ -90,11 +72,11 @@ def get_invoice_status_display(status): def get_invoice_statuses(): """Get list of all invoice statuses with translations""" return [ - ('draft', _('Draft')), - ('sent', _('Sent')), - ('paid', _('Paid')), - ('overdue', _('Overdue')), - ('cancelled', _('Cancelled')) + ("draft", _("Draft")), + ("sent", _("Sent")), + ("paid", _("Paid")), + ("overdue", _("Overdue")), + ("cancelled", _("Cancelled")), ] @@ -102,21 +84,21 @@ def get_invoice_statuses(): def get_payment_status_display(status): """Get translated display name for payment status""" status_map = { - 'unpaid': _('Unpaid'), - 'partially_paid': _('Partially Paid'), - 'fully_paid': _('Fully Paid'), - 'overpaid': _('Overpaid') + "unpaid": _("Unpaid"), + "partially_paid": _("Partially Paid"), + "fully_paid": _("Fully Paid"), + "overpaid": _("Overpaid"), } - return status_map.get(status, status.replace('_', ' ').title()) + return status_map.get(status, status.replace("_", " ").title()) def get_payment_statuses(): """Get list of all payment statuses with translations""" return [ - ('unpaid', _('Unpaid')), - ('partially_paid', _('Partially Paid')), - ('fully_paid', _('Fully Paid')), - ('overpaid', _('Overpaid')) + ("unpaid", _("Unpaid")), + ("partially_paid", _("Partially Paid")), + ("fully_paid", _("Fully Paid")), + ("overpaid", _("Overpaid")), ] @@ -124,31 +106,31 @@ def get_payment_statuses(): def get_payment_method_display(method): """Get translated display name for payment method""" method_map = { - 'cash': _('Cash'), - 'check': _('Check'), - 'bank_transfer': _('Bank Transfer'), - 'credit_card': _('Credit Card'), - 'debit_card': _('Debit Card'), - 'paypal': _('PayPal'), - 'stripe': _('Stripe'), - 'company_card': _('Company Card'), - 'other': _('Other') + "cash": _("Cash"), + "check": _("Check"), + "bank_transfer": _("Bank Transfer"), + "credit_card": _("Credit Card"), + "debit_card": _("Debit Card"), + "paypal": _("PayPal"), + "stripe": _("Stripe"), + "company_card": _("Company Card"), + "other": _("Other"), } - return method_map.get(method, method.replace('_', ' ').title()) + return method_map.get(method, method.replace("_", " ").title()) def get_payment_methods(): """Get list of all payment methods with translations""" return [ - ('cash', _('Cash')), - ('check', _('Check')), - ('bank_transfer', _('Bank Transfer')), - ('credit_card', _('Credit Card')), - ('debit_card', _('Debit Card')), - ('paypal', _('PayPal')), - ('stripe', _('Stripe')), - ('company_card', _('Company Card')), - ('other', _('Other')) + ("cash", _("Cash")), + ("check", _("Check")), + ("bank_transfer", _("Bank Transfer")), + ("credit_card", _("Credit Card")), + ("debit_card", _("Debit Card")), + ("paypal", _("PayPal")), + ("stripe", _("Stripe")), + ("company_card", _("Company Card")), + ("other", _("Other")), ] @@ -156,10 +138,10 @@ def get_payment_methods(): def get_expense_status_display(status): """Get translated display name for expense status""" status_map = { - 'pending': _('Pending'), - 'approved': _('Approved'), - 'rejected': _('Rejected'), - 'reimbursed': _('Reimbursed') + "pending": _("Pending"), + "approved": _("Approved"), + "rejected": _("Rejected"), + "reimbursed": _("Reimbursed"), } return status_map.get(status, status.capitalize()) @@ -167,10 +149,10 @@ def get_expense_status_display(status): def get_expense_statuses(): """Get list of all expense statuses with translations""" return [ - ('pending', _('Pending')), - ('approved', _('Approved')), - ('rejected', _('Rejected')), - ('reimbursed', _('Reimbursed')) + ("pending", _("Pending")), + ("approved", _("Approved")), + ("rejected", _("Rejected")), + ("reimbursed", _("Reimbursed")), ] @@ -178,16 +160,16 @@ def get_expense_statuses(): def get_expense_category_display(category): """Get translated display name for expense category""" category_map = { - 'travel': _('Travel'), - 'meals': _('Meals'), - 'accommodation': _('Accommodation'), - 'supplies': _('Supplies'), - 'software': _('Software'), - 'equipment': _('Equipment'), - 'services': _('Services'), - 'marketing': _('Marketing'), - 'training': _('Training'), - 'other': _('Other') + "travel": _("Travel"), + "meals": _("Meals"), + "accommodation": _("Accommodation"), + "supplies": _("Supplies"), + "software": _("Software"), + "equipment": _("Equipment"), + "services": _("Services"), + "marketing": _("Marketing"), + "training": _("Training"), + "other": _("Other"), } return category_map.get(category, category.capitalize()) @@ -195,16 +177,16 @@ def get_expense_category_display(category): def get_expense_categories(): """Get list of all expense categories with translations""" return [ - ('travel', _('Travel')), - ('meals', _('Meals')), - ('accommodation', _('Accommodation')), - ('supplies', _('Supplies')), - ('software', _('Software')), - ('equipment', _('Equipment')), - ('services', _('Services')), - ('marketing', _('Marketing')), - ('training', _('Training')), - ('other', _('Other')) + ("travel", _("Travel")), + ("meals", _("Meals")), + ("accommodation", _("Accommodation")), + ("supplies", _("Supplies")), + ("software", _("Software")), + ("equipment", _("Equipment")), + ("services", _("Services")), + ("marketing", _("Marketing")), + ("training", _("Training")), + ("other", _("Other")), ] @@ -234,11 +216,11 @@ def get_per_diem_statuses(): def get_job_status_display(status): """Get translated display name for import/export job status""" status_map = { - 'pending': _('Pending'), - 'processing': _('Processing'), - 'completed': _('Completed'), - 'failed': _('Failed'), - 'partial': _('Partial') + "pending": _("Pending"), + "processing": _("Processing"), + "completed": _("Completed"), + "failed": _("Failed"), + "partial": _("Partial"), } return status_map.get(status, status.capitalize()) @@ -246,11 +228,11 @@ def get_job_status_display(status): def get_job_statuses(): """Get list of all job statuses with translations""" return [ - ('pending', _('Pending')), - ('processing', _('Processing')), - ('completed', _('Completed')), - ('failed', _('Failed')), - ('partial', _('Partial')) + ("pending", _("Pending")), + ("processing", _("Processing")), + ("completed", _("Completed")), + ("failed", _("Failed")), + ("partial", _("Partial")), ] @@ -258,10 +240,10 @@ def get_job_statuses(): def get_goal_status_display(status): """Get translated display name for weekly goal status""" status_map = { - 'active': _('Active'), - 'completed': _('Completed'), - 'failed': _('Failed'), - 'cancelled': _('Cancelled') + "active": _("Active"), + "completed": _("Completed"), + "failed": _("Failed"), + "cancelled": _("Cancelled"), } return status_map.get(status, status.capitalize()) @@ -269,10 +251,10 @@ def get_goal_status_display(status): def get_goal_statuses(): """Get list of all goal statuses with translations""" return [ - ('active', _('Active')), - ('completed', _('Completed')), - ('failed', _('Failed')), - ('cancelled', _('Cancelled')) + ("active", _("Active")), + ("completed", _("Completed")), + ("failed", _("Failed")), + ("cancelled", _("Cancelled")), ] @@ -280,122 +262,103 @@ def get_goal_statuses(): def get_alert_type_display(alert_type): """Get translated display name for budget alert type""" alert_map = { - 'warning_80': _('80% Budget Warning'), - 'warning_100': _('Budget Limit Reached'), - 'over_budget': _('Over Budget') + "warning_80": _("80% Budget Warning"), + "warning_100": _("Budget Limit Reached"), + "over_budget": _("Over Budget"), } - return alert_map.get(alert_type, alert_type.replace('_', ' ').title()) + return alert_map.get(alert_type, alert_type.replace("_", " ").title()) def get_alert_level_display(alert_level): """Get translated display name for alert level""" - level_map = { - 'info': _('Info'), - 'warning': _('Warning'), - 'critical': _('Critical') - } + level_map = {"info": _("Info"), "warning": _("Warning"), "critical": _("Critical")} return level_map.get(alert_level, alert_level.capitalize()) def get_alert_levels(): """Get list of all alert levels with translations""" - return [ - ('info', _('Info')), - ('warning', _('Warning')), - ('critical', _('Critical')) - ] + return [("info", _("Info")), ("warning", _("Warning")), ("critical", _("Critical"))] # Client Status Translations def get_client_status_display(status): """Get translated display name for client status""" - status_map = { - 'active': _('Active'), - 'inactive': _('Inactive') - } + status_map = {"active": _("Active"), "inactive": _("Inactive")} return status_map.get(status, status.capitalize()) def get_client_statuses(): """Get list of all client statuses with translations""" - return [ - ('active', _('Active')), - ('inactive', _('Inactive')) - ] + return [("active", _("Active")), ("inactive", _("Inactive"))] # Generic Status Badge Classes -def get_status_badge_class(status, status_type='generic'): +def get_status_badge_class(status, status_type="generic"): """Get Tailwind CSS badge classes for status""" # Common status colors badge_classes = { # Task statuses - 'todo': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', - 'in_progress': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', - 'review': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', - 'done': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', - 'cancelled': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', - + "todo": "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", + "in_progress": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + "review": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + "done": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "cancelled": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", # Invoice/Payment statuses - 'draft': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', - 'sent': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', - 'paid': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', - 'overdue': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', - 'unpaid': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', - 'partially_paid': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', - 'fully_paid': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', - + "draft": "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", + "sent": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + "paid": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "overdue": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", + "unpaid": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + "partially_paid": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + "fully_paid": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", # Approval statuses - 'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', - 'approved': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', - 'rejected': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', - 'reimbursed': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300', - + "pending": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + "approved": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "rejected": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", + "reimbursed": "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300", # Processing statuses - 'processing': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', - 'completed': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', - 'failed': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', - 'partial': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', - + "processing": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + "completed": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "failed": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", + "partial": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", # Active/Inactive - 'active': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', - 'inactive': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', - 'archived': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', + "active": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300", + "inactive": "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", + "archived": "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", } - - return badge_classes.get(status, 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300') + + return badge_classes.get(status, "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300") def get_priority_badge_class(priority): """Get Tailwind CSS badge classes for priority""" priority_classes = { - 'low': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300', - 'medium': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', - 'high': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300', - 'urgent': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', + "low": "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300", + "medium": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300", + "high": "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300", + "urgent": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300", } - - return priority_classes.get(priority, 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300') + + return priority_classes.get(priority, "bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300") # Register these functions to be available in templates def register_i18n_filters(app): """Register i18n template filters""" - app.jinja_env.filters['task_status'] = get_task_status_display - app.jinja_env.filters['task_priority'] = get_task_priority_display - app.jinja_env.filters['project_status'] = get_project_status_display - app.jinja_env.filters['invoice_status'] = get_invoice_status_display - app.jinja_env.filters['payment_status'] = get_payment_status_display - app.jinja_env.filters['payment_method'] = get_payment_method_display - app.jinja_env.filters['expense_status'] = get_expense_status_display - app.jinja_env.filters['expense_category'] = get_expense_category_display - app.jinja_env.filters['mileage_status'] = get_mileage_status_display - app.jinja_env.filters['per_diem_status'] = get_per_diem_status_display - app.jinja_env.filters['job_status'] = get_job_status_display - app.jinja_env.filters['goal_status'] = get_goal_status_display - app.jinja_env.filters['alert_type'] = get_alert_type_display - app.jinja_env.filters['alert_level'] = get_alert_level_display - app.jinja_env.filters['client_status'] = get_client_status_display - app.jinja_env.filters['status_badge'] = get_status_badge_class - app.jinja_env.filters['priority_badge'] = get_priority_badge_class - + app.jinja_env.filters["task_status"] = get_task_status_display + app.jinja_env.filters["task_priority"] = get_task_priority_display + app.jinja_env.filters["project_status"] = get_project_status_display + app.jinja_env.filters["invoice_status"] = get_invoice_status_display + app.jinja_env.filters["payment_status"] = get_payment_status_display + app.jinja_env.filters["payment_method"] = get_payment_method_display + app.jinja_env.filters["expense_status"] = get_expense_status_display + app.jinja_env.filters["expense_category"] = get_expense_category_display + app.jinja_env.filters["mileage_status"] = get_mileage_status_display + app.jinja_env.filters["per_diem_status"] = get_per_diem_status_display + app.jinja_env.filters["job_status"] = get_job_status_display + app.jinja_env.filters["goal_status"] = get_goal_status_display + app.jinja_env.filters["alert_type"] = get_alert_type_display + app.jinja_env.filters["alert_level"] = get_alert_level_display + app.jinja_env.filters["client_status"] = get_client_status_display + app.jinja_env.filters["status_badge"] = get_status_badge_class + app.jinja_env.filters["priority_badge"] = get_priority_badge_class diff --git a/app/utils/installation.py b/app/utils/installation.py index 8a3805f5..375c791b 100644 --- a/app/utils/installation.py +++ b/app/utils/installation.py @@ -15,111 +15,107 @@ class InstallationConfig: """Manages installation-specific configuration""" - + CONFIG_DIR = "data" CONFIG_FILE = "installation.json" - + def __init__(self): self.config_path = os.path.join(self.CONFIG_DIR, self.CONFIG_FILE) self._ensure_config_dir() self._config = self._load_config() - + def _ensure_config_dir(self): """Ensure the configuration directory exists""" os.makedirs(self.CONFIG_DIR, exist_ok=True) - + def _load_config(self) -> Dict: """Load configuration from file""" if os.path.exists(self.config_path): try: - with open(self.config_path, 'r') as f: + with open(self.config_path, "r") as f: return json.load(f) except Exception: return {} return {} - + def _save_config(self): """Save configuration to file""" try: - with open(self.config_path, 'w') as f: + with open(self.config_path, "w") as f: json.dump(self._config, f, indent=2) except Exception as e: print(f"Error saving installation config: {e}") - + def get_installation_salt(self) -> str: """ Get or generate installation-specific salt for telemetry. - + This salt is unique per installation and persists across restarts. It's used to generate consistent anonymous fingerprints. """ - if 'telemetry_salt' not in self._config: + if "telemetry_salt" not in self._config: # Generate a unique 64-character hex salt salt = secrets.token_hex(32) # 32 bytes = 64 hex characters - self._config['telemetry_salt'] = salt + self._config["telemetry_salt"] = salt self._save_config() - return self._config['telemetry_salt'] - + return self._config["telemetry_salt"] + def get_installation_id(self) -> str: """ Get or generate a unique installation ID. - + This is a one-way hash that uniquely identifies this installation without revealing any server information. """ - if 'installation_id' not in self._config: + if "installation_id" not in self._config: # Generate a unique installation ID import platform import time - + # Combine multiple factors for uniqueness - factors = [ - platform.node() or 'unknown', - str(time.time()), - secrets.token_hex(16) - ] - + factors = [platform.node() or "unknown", str(time.time()), secrets.token_hex(16)] + # Hash to create installation ID - combined = ''.join(factors).encode() + combined = "".join(factors).encode() installation_id = hashlib.sha256(combined).hexdigest()[:16] - - self._config['installation_id'] = installation_id + + self._config["installation_id"] = installation_id self._save_config() - - return self._config['installation_id'] - + + return self._config["installation_id"] + def is_setup_complete(self) -> bool: """Check if initial setup is complete""" - return self._config.get('setup_complete', False) - + return self._config.get("setup_complete", False) + def mark_setup_complete(self, telemetry_enabled: bool = False): """Mark initial setup as complete""" - self._config['setup_complete'] = True - self._config['telemetry_enabled'] = telemetry_enabled - self._config['setup_completed_at'] = str(datetime.now()) + self._config["setup_complete"] = True + self._config["telemetry_enabled"] = telemetry_enabled + self._config["setup_completed_at"] = str(datetime.now()) self._save_config() - + def is_initial_data_seeded(self) -> bool: """Check if initial database data (default client/project) has been seeded""" - return self._config.get('initial_data_seeded', False) - + return self._config.get("initial_data_seeded", False) + def mark_initial_data_seeded(self): """Mark that initial database data has been seeded""" - self._config['initial_data_seeded'] = True - self._config['initial_data_seeded_at'] = str(datetime.now()) + self._config["initial_data_seeded"] = True + self._config["initial_data_seeded_at"] = str(datetime.now()) self._save_config() - + def get_telemetry_preference(self) -> bool: """Get user's telemetry preference""" # Reload on read to reflect external updates (e.g., tests toggling state) self._config = self._load_config() - return self._config.get('telemetry_enabled', False) - + return self._config.get("telemetry_enabled", False) + def set_telemetry_preference(self, enabled: bool): """Set user's telemetry preference""" - self._config['telemetry_enabled'] = enabled + self._config["telemetry_enabled"] = enabled self._save_config() - + def get_all_config(self) -> Dict: """Get all configuration (for admin dashboard)""" return self._config.copy() @@ -144,4 +140,3 @@ def get_installation_config() -> InstallationConfig: # Add missing datetime import from datetime import datetime - diff --git a/app/utils/logger.py b/app/utils/logger.py index 222defb7..f0c280fb 100644 --- a/app/utils/logger.py +++ b/app/utils/logger.py @@ -11,24 +11,20 @@ def get_logger(name: str) -> logging.Logger: """ Get a logger instance. - + Args: name: Logger name (usually __name__) - + Returns: Logger instance """ return logging.getLogger(name) -def log_request( - logger: logging.Logger, - level: int = logging.INFO, - extra: Optional[Dict[str, Any]] = None -) -> None: +def log_request(logger: logging.Logger, level: int = logging.INFO, extra: Optional[Dict[str, Any]] = None) -> None: """ Log request information. - + Args: logger: Logger instance level: Log level @@ -36,61 +32,48 @@ def log_request( """ if not request: return - + context = { - 'method': request.method, - 'path': request.path, - 'remote_addr': request.remote_addr, - 'user_agent': request.headers.get('User-Agent'), - 'request_id': getattr(g, 'request_id', None) + "method": request.method, + "path": request.path, + "remote_addr": request.remote_addr, + "user_agent": request.headers.get("User-Agent"), + "request_id": getattr(g, "request_id", None), } - + if extra: context.update(extra) - + logger.log(level, f"{request.method} {request.path}", extra=context) -def log_error( - logger: logging.Logger, - error: Exception, - context: Optional[Dict[str, Any]] = None -) -> None: +def log_error(logger: logging.Logger, error: Exception, context: Optional[Dict[str, Any]] = None) -> None: """ Log an error with context. - + Args: logger: Logger instance error: Exception to log context: Additional context """ error_context = { - 'error_type': type(error).__name__, - 'error_message': str(error), - 'request_id': getattr(g, 'request_id', None), - 'path': request.path if request else None, - 'method': request.method if request else None + "error_type": type(error).__name__, + "error_message": str(error), + "request_id": getattr(g, "request_id", None), + "path": request.path if request else None, + "method": request.method if request else None, } - + if context: error_context.update(context) - - logger.error( - f"Error: {error}", - exc_info=True, - extra=error_context - ) - - -def log_business_event( - logger: logging.Logger, - event: str, - user_id: Optional[int] = None, - **kwargs -) -> None: + + logger.error(f"Error: {error}", exc_info=True, extra=error_context) + + +def log_business_event(logger: logging.Logger, event: str, user_id: Optional[int] = None, **kwargs) -> None: """ Log a business event. - + Args: logger: Logger instance event: Event name @@ -98,37 +81,27 @@ def log_business_event( **kwargs: Additional event data """ event_data = { - 'event': event, - 'user_id': user_id, - 'request_id': getattr(g, 'request_id', None), - 'path': request.path if request else None + "event": event, + "user_id": user_id, + "request_id": getattr(g, "request_id", None), + "path": request.path if request else None, } event_data.update(kwargs) - + logger.info(f"Business event: {event}", extra=event_data) -def log_performance( - logger: logging.Logger, - operation: str, - duration: float, - **kwargs -) -> None: +def log_performance(logger: logging.Logger, operation: str, duration: float, **kwargs) -> None: """ Log performance metrics. - + Args: logger: Logger instance operation: Operation name duration: Duration in seconds **kwargs: Additional metrics """ - metrics = { - 'operation': operation, - 'duration': duration, - 'request_id': getattr(g, 'request_id', None) - } + metrics = {"operation": operation, "duration": duration, "request_id": getattr(g, "request_id", None)} metrics.update(kwargs) - - logger.info(f"Performance: {operation} took {duration:.4f}s", extra=metrics) + logger.info(f"Performance: {operation} took {duration:.4f}s", extra=metrics) diff --git a/app/utils/ocr.py b/app/utils/ocr.py index 115b6c45..c619f265 100644 --- a/app/utils/ocr.py +++ b/app/utils/ocr.py @@ -17,6 +17,7 @@ try: import pytesseract from PIL import Image + TESSERACT_AVAILABLE = True except ImportError: TESSERACT_AVAILABLE = False @@ -28,31 +29,31 @@ def is_ocr_available(): return TESSERACT_AVAILABLE -def extract_text_from_image(image_path, lang='eng'): +def extract_text_from_image(image_path, lang="eng"): """ Extract text from an image using Tesseract OCR. - + Args: image_path: Path to the image file lang: OCR language (default: 'eng', can be 'eng+deu' for multilingual) - + Returns: Extracted text as string """ if not TESSERACT_AVAILABLE: raise RuntimeError("Tesseract OCR is not available. Install pytesseract and PIL.") - + try: # Open and preprocess image image = Image.open(image_path) - + # Convert to RGB if necessary - if image.mode != 'RGB': - image = image.convert('RGB') - + if image.mode != "RGB": + image = image.convert("RGB") + # Extract text text = pytesseract.image_to_string(image, lang=lang) - + return text except Exception as e: logger.error(f"Error extracting text from image {image_path}: {e}") @@ -62,142 +63,137 @@ def extract_text_from_image(image_path, lang='eng'): def parse_receipt_data(text): """ Parse common receipt information from extracted text. - + Args: text: Extracted text from receipt - + Returns: Dictionary with parsed data (vendor, date, total, items, etc.) """ data = { - 'vendor': None, - 'date': None, - 'total': None, - 'tax': None, - 'subtotal': None, - 'items': [], - 'currency': 'EUR', - 'raw_text': text + "vendor": None, + "date": None, + "total": None, + "tax": None, + "subtotal": None, + "items": [], + "currency": "EUR", + "raw_text": text, } - - lines = text.split('\n') - + + lines = text.split("\n") + # Try to extract vendor (usually first few lines) vendor_lines = [] for line in lines[:5]: line = line.strip() if line and len(line) > 3: vendor_lines.append(line) - + if vendor_lines: - data['vendor'] = vendor_lines[0] - + data["vendor"] = vendor_lines[0] + # Extract amounts amounts = extract_amounts(text) if amounts: # Try to identify total (usually largest amount or labeled as total) total_candidates = [] - + for amount_info in amounts: - label = amount_info.get('label', '').lower() - if any(keyword in label for keyword in ['total', 'gesamt', 'suma', 'totale']): - data['total'] = amount_info['amount'] - elif any(keyword in label for keyword in ['tax', 'vat', 'mwst', 'iva', 'tva']): - data['tax'] = amount_info['amount'] - elif any(keyword in label for keyword in ['subtotal', 'zwischensumme', 'sous-total']): - data['subtotal'] = amount_info['amount'] + label = amount_info.get("label", "").lower() + if any(keyword in label for keyword in ["total", "gesamt", "suma", "totale"]): + data["total"] = amount_info["amount"] + elif any(keyword in label for keyword in ["tax", "vat", "mwst", "iva", "tva"]): + data["tax"] = amount_info["amount"] + elif any(keyword in label for keyword in ["subtotal", "zwischensumme", "sous-total"]): + data["subtotal"] = amount_info["amount"] else: - total_candidates.append(amount_info['amount']) - + total_candidates.append(amount_info["amount"]) + # If no labeled total found, use the largest amount - if not data['total'] and total_candidates: - data['total'] = max(total_candidates) - + if not data["total"] and total_candidates: + data["total"] = max(total_candidates) + # Extract date date = extract_date(text) if date: - data['date'] = date - + data["date"] = date + # Extract currency currency = extract_currency(text) if currency: - data['currency'] = currency - + data["currency"] = currency + return data def extract_amounts(text): """ Extract monetary amounts from text. - + Returns: List of dictionaries with 'amount' and 'label' keys """ amounts = [] - + # Patterns for amounts (supports various formats) # Examples: 12.34, 12,34, $12.34, €12,34, 12.34 EUR patterns = [ - r'([A-Za-z\s]*?)\s*([$€£¥]?)\s*(\d{1,3}(?:[.,]\d{3})*[.,]\d{2})\s*([A-Z]{3})?', + r"([A-Za-z\s]*?)\s*([$€£¥]?)\s*(\d{1,3}(?:[.,]\d{3})*[.,]\d{2})\s*([A-Z]{3})?", ] - + for pattern in patterns: matches = re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE) for match in matches: - label = match.group(1).strip() if match.group(1) else '' - symbol = match.group(2) if match.group(2) else '' + label = match.group(1).strip() if match.group(1) else "" + symbol = match.group(2) if match.group(2) else "" amount_str = match.group(3) - currency = match.group(4) if match.group(4) else '' - + currency = match.group(4) if match.group(4) else "" + # Normalize amount (convert comma to dot if needed) # Determine if comma or dot is decimal separator - if ',' in amount_str and '.' in amount_str: + if "," in amount_str and "." in amount_str: # Has both, assume European format (1.234,56) - amount_str = amount_str.replace('.', '').replace(',', '.') - elif ',' in amount_str: + amount_str = amount_str.replace(".", "").replace(",", ".") + elif "," in amount_str: # Only comma, check if it's thousands separator or decimal - parts = amount_str.split(',') + parts = amount_str.split(",") if len(parts) == 2 and len(parts[1]) == 2: # Likely decimal separator - amount_str = amount_str.replace(',', '.') + amount_str = amount_str.replace(",", ".") else: # Likely thousands separator - amount_str = amount_str.replace(',', '') - + amount_str = amount_str.replace(",", "") + try: amount = Decimal(amount_str) - amounts.append({ - 'amount': amount, - 'label': label, - 'symbol': symbol, - 'currency': currency - }) + amounts.append({"amount": amount, "label": label, "symbol": symbol, "currency": currency}) except (ValueError, Decimal.InvalidOperation): continue - + return amounts def extract_date(text): """ Extract date from receipt text. - + Returns: datetime.date object or None """ # Common date patterns patterns = [ - r'(\d{1,2})[./\-](\d{1,2})[./\-](\d{2,4})', # DD/MM/YYYY or MM/DD/YYYY - r'(\d{4})[./\-](\d{1,2})[./\-](\d{1,2})', # YYYY-MM-DD - r'(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{2,4})', # DD Month YYYY + r"(\d{1,2})[./\-](\d{1,2})[./\-](\d{2,4})", # DD/MM/YYYY or MM/DD/YYYY + r"(\d{4})[./\-](\d{1,2})[./\-](\d{1,2})", # YYYY-MM-DD + r"(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{2,4})", # DD Month YYYY ] - + for pattern in patterns: match = re.search(pattern, text, re.IGNORECASE) if match: try: groups = match.groups() - + if len(groups) == 3: if pattern == patterns[0]: # DD/MM/YYYY or MM/DD/YYYY # Try DD/MM/YYYY first (European format) @@ -215,130 +211,128 @@ def extract_date(text): return datetime(year, month, day).date() except ValueError: continue - + elif pattern == patterns[1]: # YYYY-MM-DD year, month, day = int(groups[0]), int(groups[1]), int(groups[2]) return datetime(year, month, day).date() - + elif pattern == patterns[2]: # DD Month YYYY day = int(groups[0]) month_str = groups[1].lower() year = int(groups[2]) if year < 100: year += 2000 - + months = { - 'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4, - 'may': 5, 'jun': 6, 'jul': 7, 'aug': 8, - 'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12 + "jan": 1, + "feb": 2, + "mar": 3, + "apr": 4, + "may": 5, + "jun": 6, + "jul": 7, + "aug": 8, + "sep": 9, + "oct": 10, + "nov": 11, + "dec": 12, } month = months.get(month_str[:3]) if month: return datetime(year, month, day).date() - + except (ValueError, TypeError): continue - + return None def extract_currency(text): """ Extract currency code from receipt text. - + Returns: 3-letter currency code (ISO 4217) or 'EUR' as default """ # Currency symbols and their codes - currency_symbols = { - '$': 'USD', - '€': 'EUR', - '£': 'GBP', - '¥': 'JPY', - '₹': 'INR', - 'Fr': 'CHF' - } - + currency_symbols = {"$": "USD", "€": "EUR", "£": "GBP", "¥": "JPY", "₹": "INR", "Fr": "CHF"} + # Look for currency symbols for symbol, code in currency_symbols.items(): if symbol in text: return code - + # Look for currency codes (3 uppercase letters) - currency_pattern = r'\b([A-Z]{3})\b' + currency_pattern = r"\b([A-Z]{3})\b" matches = re.findall(currency_pattern, text) - + # Common currency codes - common_currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'INR'] - + common_currencies = ["USD", "EUR", "GBP", "JPY", "CHF", "CAD", "AUD", "INR"] + for match in matches: if match in common_currencies: return match - - return 'EUR' # Default + + return "EUR" # Default -def scan_receipt(image_path, lang='eng'): +def scan_receipt(image_path, lang="eng"): """ Scan a receipt image and extract structured data. - + Args: image_path: Path to the receipt image lang: OCR language(s) to use (e.g., 'eng', 'eng+deu') - + Returns: Dictionary with extracted receipt data """ if not is_ocr_available(): return { - 'error': 'OCR not available', - 'message': 'Please install pytesseract and Pillow: pip install pytesseract pillow' + "error": "OCR not available", + "message": "Please install pytesseract and Pillow: pip install pytesseract pillow", } - + try: # Extract text text = extract_text_from_image(image_path, lang=lang) - + # Parse data data = parse_receipt_data(text) - + return data - + except Exception as e: logger.error(f"Error scanning receipt {image_path}: {e}") - return { - 'error': str(e), - 'message': 'Failed to scan receipt' - } + return {"error": str(e), "message": "Failed to scan receipt"} def get_suggested_expense_data(receipt_data): """ Convert receipt data to expense form data suggestions. - + Args: receipt_data: Dictionary returned by scan_receipt() - + Returns: Dictionary with suggested expense data """ suggestions = {} - - if receipt_data.get('vendor'): - suggestions['vendor'] = receipt_data['vendor'] - suggestions['title'] = f"Receipt from {receipt_data['vendor']}" - - if receipt_data.get('total'): - suggestions['amount'] = float(receipt_data['total']) - - if receipt_data.get('tax'): - suggestions['tax_amount'] = float(receipt_data['tax']) - - if receipt_data.get('date'): - suggestions['expense_date'] = receipt_data['date'].isoformat() - - if receipt_data.get('currency'): - suggestions['currency_code'] = receipt_data['currency'] - - return suggestions + if receipt_data.get("vendor"): + suggestions["vendor"] = receipt_data["vendor"] + suggestions["title"] = f"Receipt from {receipt_data['vendor']}" + + if receipt_data.get("total"): + suggestions["amount"] = float(receipt_data["total"]) + + if receipt_data.get("tax"): + suggestions["tax_amount"] = float(receipt_data["tax"]) + + if receipt_data.get("date"): + suggestions["expense_date"] = receipt_data["date"].isoformat() + + if receipt_data.get("currency"): + suggestions["currency_code"] = receipt_data["currency"] + + return suggestions diff --git a/app/utils/overtime.py b/app/utils/overtime.py index 01fc0e82..f2041807 100644 --- a/app/utils/overtime.py +++ b/app/utils/overtime.py @@ -12,11 +12,11 @@ def calculate_daily_overtime(total_hours: float, standard_hours: float) -> float: """ Calculate overtime hours for a single day. - + Args: total_hours: Total hours worked in a day standard_hours: Standard working hours per day - + Returns: Overtime hours (0 if no overtime) """ @@ -26,54 +26,52 @@ def calculate_daily_overtime(total_hours: float, standard_hours: float) -> float def calculate_period_overtime( - user, - start_date: date, - end_date: date, - include_weekends: bool = True + user, start_date: date, end_date: date, include_weekends: bool = True ) -> Dict[str, float]: """ Calculate overtime for a specific period. - + Args: user: User object with standard_hours_per_day setting start_date: Start date of the period end_date: End date of the period include_weekends: Whether to count weekend hours as overtime - + Returns: Dictionary with regular_hours, overtime_hours, and total_hours """ from app.models import TimeEntry from app import db - + # Get all time entries for the period # Convert dates to datetime ranges to include full day from datetime import datetime as dt + start_datetime = dt.combine(start_date, dt.min.time()) end_datetime = dt.combine(end_date, dt.max.time()) - + entries = TimeEntry.query.filter( TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_datetime, - TimeEntry.start_time <= end_datetime + TimeEntry.start_time <= end_datetime, ).all() - + # Group entries by date daily_hours = {} for entry in entries: entry_date = entry.start_time.date() hours = entry.duration_hours - + if entry_date not in daily_hours: daily_hours[entry_date] = 0.0 daily_hours[entry_date] += hours - + # Calculate overtime per day standard_hours = user.standard_hours_per_day total_regular = 0.0 total_overtime = 0.0 - + for day_date, hours in daily_hours.items(): # Check if weekend if not include_weekends and day_date.weekday() >= 5: # Saturday=5, Sunday=6 @@ -85,227 +83,212 @@ def calculate_period_overtime( total_regular += hours else: total_regular += standard_hours - total_overtime += (hours - standard_hours) - + total_overtime += hours - standard_hours + return { - 'regular_hours': round(total_regular, 2), - 'overtime_hours': round(total_overtime, 2), - 'total_hours': round(total_regular + total_overtime, 2), - 'days_with_overtime': sum(1 for h in daily_hours.values() if h > standard_hours) + "regular_hours": round(total_regular, 2), + "overtime_hours": round(total_overtime, 2), + "total_hours": round(total_regular + total_overtime, 2), + "days_with_overtime": sum(1 for h in daily_hours.values() if h > standard_hours), } -def get_daily_breakdown( - user, - start_date: date, - end_date: date -) -> List[Dict]: +def get_daily_breakdown(user, start_date: date, end_date: date) -> List[Dict]: """ Get a daily breakdown of regular and overtime hours. - + Args: user: User object with standard_hours_per_day setting start_date: Start date of the period end_date: End date of the period - + Returns: List of dictionaries with daily breakdown """ from app.models import TimeEntry from app import db - + # Get all time entries for the period # Convert dates to datetime ranges to include full day from datetime import datetime as dt + start_datetime = dt.combine(start_date, dt.min.time()) end_datetime = dt.combine(end_date, dt.max.time()) - - entries = TimeEntry.query.filter( - TimeEntry.user_id == user.id, - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= start_datetime, - TimeEntry.start_time <= end_datetime - ).order_by(TimeEntry.start_time).all() - + + entries = ( + TimeEntry.query.filter( + TimeEntry.user_id == user.id, + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_datetime, + TimeEntry.start_time <= end_datetime, + ) + .order_by(TimeEntry.start_time) + .all() + ) + # Group entries by date daily_data = {} for entry in entries: entry_date = entry.start_time.date() - + if entry_date not in daily_data: - daily_data[entry_date] = { - 'date': entry_date, - 'total_hours': 0.0, - 'entries': [] - } - - daily_data[entry_date]['total_hours'] += entry.duration_hours - daily_data[entry_date]['entries'].append(entry) - + daily_data[entry_date] = {"date": entry_date, "total_hours": 0.0, "entries": []} + + daily_data[entry_date]["total_hours"] += entry.duration_hours + daily_data[entry_date]["entries"].append(entry) + # Calculate overtime for each day standard_hours = user.standard_hours_per_day breakdown = [] - + for day_date in sorted(daily_data.keys()): day_info = daily_data[day_date] - total_hours = day_info['total_hours'] - + total_hours = day_info["total_hours"] + regular_hours = min(total_hours, standard_hours) overtime_hours = max(0, total_hours - standard_hours) - - breakdown.append({ - 'date': day_date, - 'date_str': day_date.strftime('%Y-%m-%d'), - 'weekday': day_date.strftime('%A'), - 'total_hours': round(total_hours, 2), - 'regular_hours': round(regular_hours, 2), - 'overtime_hours': round(overtime_hours, 2), - 'is_overtime': overtime_hours > 0, - 'entries_count': len(day_info['entries']) - }) - + + breakdown.append( + { + "date": day_date, + "date_str": day_date.strftime("%Y-%m-%d"), + "weekday": day_date.strftime("%A"), + "total_hours": round(total_hours, 2), + "regular_hours": round(regular_hours, 2), + "overtime_hours": round(overtime_hours, 2), + "is_overtime": overtime_hours > 0, + "entries_count": len(day_info["entries"]), + } + ) + return breakdown -def get_weekly_overtime_summary( - user, - weeks: int = 4 -) -> List[Dict]: +def get_weekly_overtime_summary(user, weeks: int = 4) -> List[Dict]: """ Get a weekly summary of overtime for the last N weeks. - + Args: user: User object with standard_hours_per_day setting weeks: Number of weeks to look back - + Returns: List of weekly summaries """ from app.models import TimeEntry from app import db - + end_date = datetime.now().date() start_date = end_date - timedelta(weeks=weeks) - + # Convert dates to datetime ranges to include full day start_datetime = datetime.combine(start_date, datetime.min.time()) end_datetime = datetime.combine(end_date, datetime.max.time()) - + # Get all time entries entries = TimeEntry.query.filter( TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_datetime, - TimeEntry.start_time <= end_datetime + TimeEntry.start_time <= end_datetime, ).all() - + # Group by week weekly_data = {} for entry in entries: entry_date = entry.start_time.date() # Get Monday of that week week_start = entry_date - timedelta(days=entry_date.weekday()) - + if week_start not in weekly_data: weekly_data[week_start] = {} - + if entry_date not in weekly_data[week_start]: weekly_data[week_start][entry_date] = 0.0 - + weekly_data[week_start][entry_date] += entry.duration_hours - + # Calculate overtime per week standard_hours = user.standard_hours_per_day weekly_summary = [] - + for week_start in sorted(weekly_data.keys()): daily_hours = weekly_data[week_start] - + week_regular = 0.0 week_overtime = 0.0 - + for day_date, hours in daily_hours.items(): if hours <= standard_hours: week_regular += hours else: week_regular += standard_hours - week_overtime += (hours - standard_hours) - + week_overtime += hours - standard_hours + week_end = week_start + timedelta(days=6) - - weekly_summary.append({ - 'week_start': week_start, - 'week_end': week_end, - 'week_label': f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}", - 'regular_hours': round(week_regular, 2), - 'overtime_hours': round(week_overtime, 2), - 'total_hours': round(week_regular + week_overtime, 2), - 'days_worked': len(daily_hours) - }) - + + weekly_summary.append( + { + "week_start": week_start, + "week_end": week_end, + "week_label": f"{week_start.strftime('%b %d')} - {week_end.strftime('%b %d')}", + "regular_hours": round(week_regular, 2), + "overtime_hours": round(week_overtime, 2), + "total_hours": round(week_regular + week_overtime, 2), + "days_worked": len(daily_hours), + } + ) + return weekly_summary -def get_overtime_statistics( - user, - start_date: date, - end_date: date -) -> Dict: +def get_overtime_statistics(user, start_date: date, end_date: date) -> Dict: """ Get comprehensive overtime statistics for a period. - + Args: user: User object start_date: Start date end_date: End date - + Returns: Dictionary with various overtime statistics """ period_data = calculate_period_overtime(user, start_date, end_date) daily_breakdown = get_daily_breakdown(user, start_date, end_date) - + # Calculate additional statistics days_worked = len(daily_breakdown) - days_with_overtime = sum(1 for day in daily_breakdown if day['is_overtime']) - + days_with_overtime = sum(1 for day in daily_breakdown if day["is_overtime"]) + # Average hours per day - avg_hours_per_day = ( - period_data['total_hours'] / days_worked if days_worked > 0 else 0 - ) - + avg_hours_per_day = period_data["total_hours"] / days_worked if days_worked > 0 else 0 + # Max overtime in a single day max_overtime_day = max( - (day for day in daily_breakdown if day['is_overtime']), - key=lambda x: x['overtime_hours'], - default=None + (day for day in daily_breakdown if day["is_overtime"]), key=lambda x: x["overtime_hours"], default=None ) - + return { - 'period': { - 'start_date': start_date.strftime('%Y-%m-%d'), - 'end_date': end_date.strftime('%Y-%m-%d'), - 'days_in_period': (end_date - start_date).days + 1 + "period": { + "start_date": start_date.strftime("%Y-%m-%d"), + "end_date": end_date.strftime("%Y-%m-%d"), + "days_in_period": (end_date - start_date).days + 1, + }, + "hours": period_data, + "days_statistics": { + "days_worked": days_worked, + "days_with_overtime": days_with_overtime, + "percentage_overtime_days": (round(days_with_overtime / days_worked * 100, 1) if days_worked > 0 else 0), }, - 'hours': period_data, - 'days_statistics': { - 'days_worked': days_worked, - 'days_with_overtime': days_with_overtime, - 'percentage_overtime_days': ( - round(days_with_overtime / days_worked * 100, 1) - if days_worked > 0 else 0 - ) + "averages": { + "avg_hours_per_day": round(avg_hours_per_day, 2), + "avg_overtime_per_overtime_day": ( + round(period_data["overtime_hours"] / days_with_overtime, 2) if days_with_overtime > 0 else 0 + ), }, - 'averages': { - 'avg_hours_per_day': round(avg_hours_per_day, 2), - 'avg_overtime_per_overtime_day': ( - round(period_data['overtime_hours'] / days_with_overtime, 2) - if days_with_overtime > 0 else 0 - ) + "max_overtime": { + "date": max_overtime_day["date_str"] if max_overtime_day else None, + "hours": max_overtime_day["overtime_hours"] if max_overtime_day else 0, }, - 'max_overtime': { - 'date': max_overtime_day['date_str'] if max_overtime_day else None, - 'hours': max_overtime_day['overtime_hours'] if max_overtime_day else 0 - } } - diff --git a/app/utils/pagination.py b/app/utils/pagination.py index 04291693..e9175c95 100644 --- a/app/utils/pagination.py +++ b/app/utils/pagination.py @@ -9,95 +9,80 @@ def paginate_query( - query: Query, - page: Optional[int] = None, - per_page: Optional[int] = None, - max_per_page: int = MAX_PAGE_SIZE + query: Query, page: Optional[int] = None, per_page: Optional[int] = None, max_per_page: int = MAX_PAGE_SIZE ) -> Dict[str, Any]: """ Paginate a SQLAlchemy query. - + Args: query: SQLAlchemy query object page: Page number (defaults to request arg or 1) per_page: Items per page (defaults to request arg or DEFAULT_PAGE_SIZE) max_per_page: Maximum items per page - + Returns: dict with 'items' and 'pagination' keys """ # Get pagination parameters - page = page or int(request.args.get('page', 1)) if request else 1 - per_page = per_page or int(request.args.get('per_page', DEFAULT_PAGE_SIZE)) if request else DEFAULT_PAGE_SIZE - + page = page or int(request.args.get("page", 1)) if request else 1 + per_page = per_page or int(request.args.get("per_page", DEFAULT_PAGE_SIZE)) if request else DEFAULT_PAGE_SIZE + # Enforce maximum per_page = min(per_page, max_per_page) - + # Paginate - paginated = query.paginate( - page=page, - per_page=per_page, - error_out=False - ) - + paginated = query.paginate(page=page, per_page=per_page, error_out=False) + return { - 'items': paginated.items, - 'pagination': { - 'page': paginated.page, - 'per_page': paginated.per_page, - 'total': paginated.total, - 'pages': paginated.pages, - 'has_next': paginated.has_next, - 'has_prev': paginated.has_prev, - 'next_page': paginated.page + 1 if paginated.has_next else None, - 'prev_page': paginated.page - 1 if paginated.has_prev else None - } + "items": paginated.items, + "pagination": { + "page": paginated.page, + "per_page": paginated.per_page, + "total": paginated.total, + "pages": paginated.pages, + "has_next": paginated.has_next, + "has_prev": paginated.has_prev, + "next_page": paginated.page + 1 if paginated.has_next else None, + "prev_page": paginated.page - 1 if paginated.has_prev else None, + }, } def get_pagination_params( - default_page: int = 1, - default_per_page: int = DEFAULT_PAGE_SIZE, - max_per_page: int = MAX_PAGE_SIZE + default_page: int = 1, default_per_page: int = DEFAULT_PAGE_SIZE, max_per_page: int = MAX_PAGE_SIZE ) -> tuple[int, int]: """ Get pagination parameters from request. - + Returns: tuple of (page, per_page) """ - page = int(request.args.get('page', default_page)) if request else default_page - per_page = int(request.args.get('per_page', default_per_page)) if request else default_per_page + page = int(request.args.get("page", default_page)) if request else default_page + per_page = int(request.args.get("per_page", default_per_page)) if request else default_per_page per_page = min(per_page, max_per_page) return page, per_page -def create_pagination_links( - page: int, - per_page: int, - total: int, - base_url: str -) -> Dict[str, Optional[str]]: +def create_pagination_links(page: int, per_page: int, total: int, base_url: str) -> Dict[str, Optional[str]]: """ Create pagination links. - + Args: page: Current page per_page: Items per page total: Total items base_url: Base URL for links - + Returns: dict with pagination links """ pages = (total + per_page - 1) // per_page if total > 0 else 0 - + links = { - 'first': f"{base_url}?page=1&per_page={per_page}" if page > 1 else None, - 'last': f"{base_url}?page={pages}&per_page={per_page}" if pages > 0 and page < pages else None, - 'prev': f"{base_url}?page={page-1}&per_page={per_page}" if page > 1 else None, - 'next': f"{base_url}?page={page+1}&per_page={per_page}" if page < pages else None + "first": f"{base_url}?page=1&per_page={per_page}" if page > 1 else None, + "last": f"{base_url}?page={pages}&per_page={per_page}" if pages > 0 and page < pages else None, + "prev": f"{base_url}?page={page-1}&per_page={per_page}" if page > 1 else None, + "next": f"{base_url}?page={page+1}&per_page={per_page}" if page < pages else None, } - - return links + return links diff --git a/app/utils/pdf_generator.py b/app/utils/pdf_generator.py index 693770a6..bf5cb2ec 100644 --- a/app/utils/pdf_generator.py +++ b/app/utils/pdf_generator.py @@ -6,10 +6,12 @@ import os import html as html_lib from datetime import datetime + try: # Try importing WeasyPrint. This may fail on systems without native deps. from weasyprint import HTML, CSS # type: ignore from weasyprint.text.fonts import FontConfiguration # type: ignore + _WEASYPRINT_AVAILABLE = True except Exception: # Defer to fallback implementation at runtime @@ -21,6 +23,7 @@ from app import db from flask import current_app from flask_babel import gettext as _ + try: from babel.dates import format_date as babel_format_date except Exception: @@ -28,48 +31,51 @@ from pathlib import Path from flask import render_template + class InvoicePDFGenerator: """Generate PDF invoices with company branding""" - - def __init__(self, invoice, settings=None, page_size='A4'): + + def __init__(self, invoice, settings=None, page_size="A4"): self.invoice = invoice self.settings = settings or Settings.get_settings() - self.page_size = page_size or 'A4' - + self.page_size = page_size or "A4" + def generate_pdf(self): """Generate PDF content and return as bytes""" # If WeasyPrint isn't available or explicitly disabled, use the fallback if (not _WEASYPRINT_AVAILABLE) or os.getenv("DISABLE_WEASYPRINT", "").lower() in ("1", "true", "yes"): from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback + fallback = InvoicePDFGeneratorFallback(self.invoice, settings=self.settings) return fallback.generate_pdf() # Enable debugging - output directly to stdout for Docker console visibility import sys - + # Force unbuffered output to stdout - this ensures Docker sees it immediately def debug_print(msg): """Print debug message to stdout with immediate flush for Docker visibility""" print(msg, file=sys.stdout, flush=True) # Also try stderr print(msg, file=sys.stderr, flush=True) - + # Header - make it very visible - print("\n" + "="*80, file=sys.stdout, flush=True) + print("\n" + "=" * 80, file=sys.stdout, flush=True) print("PDF GENERATOR generate_pdf() CALLED", file=sys.stdout, flush=True) - print("="*80, file=sys.stdout, flush=True) + print("=" * 80, file=sys.stdout, flush=True) debug_print(f"\nPDF GENERATOR DEBUG - Page Size: {self.page_size}") debug_print(f"{'='*80}\n") - + # Get template for the specified page size # Refresh the template from DB to ensure we have the latest version from app.models import InvoicePDFTemplate + db.session.expire_all() # Clear any cached data template = InvoicePDFTemplate.query.filter_by(page_size=self.page_size).first() if not template: template = InvoicePDFTemplate.get_template(self.page_size) - + debug_print(f"[DEBUG] Retrieved template: page_size={template.page_size}, id={template.id}") - + # Verify we got the correct template if template.page_size != self.page_size: debug_print(f"[WARNING] Template page_size mismatch! Expected {self.page_size}, got {template.page_size}") @@ -77,33 +83,34 @@ def debug_print(msg): template = InvoicePDFTemplate.query.filter_by(page_size=self.page_size).first() if not template: template = InvoicePDFTemplate.get_template(self.page_size) - + # Check if this size-specific template has content # Use raw content - preserve exact content as saved - template_html = template.template_html or '' - template_css = template.template_css or '' - + template_html = template.template_html or "" + template_css = template.template_css or "" + debug_print(f"[DEBUG] Template content - HTML length={len(template_html)}, CSS length={len(template_css)}") - + if template_html: - html_preview = template_html[:200].replace('\n', '\\n') + html_preview = template_html[:200].replace("\n", "\\n") debug_print(f"[DEBUG] Template HTML preview (first 200 chars): {html_preview}") - + if template_css: - css_preview = template_css[:200].replace('\n', '\\n') + css_preview = template_css[:200].replace("\n", "\\n") debug_print(f"[DEBUG] Template CSS preview (first 200 chars): {css_preview}") - + # Check for @page rules in CSS import re - page_rules = re.findall(r'@page\s*\{[^}]*\}', template_css, re.IGNORECASE | re.DOTALL) + + page_rules = re.findall(r"@page\s*\{[^}]*\}", template_css, re.IGNORECASE | re.DOTALL) if page_rules: debug_print(f"[DEBUG] Found {len(page_rules)} @page rule(s) in template CSS:") for i, rule in enumerate(page_rules): debug_print(f"[DEBUG] @page rule {i+1}: {rule[:100]}") - + # Check if template has meaningful content (not just whitespace) has_custom_template = bool(template_html.strip() or template_css.strip()) - + # Only use this template if it has content for this specific size if has_custom_template: debug_print(f"[DEBUG] Using custom template for page size {self.page_size}") @@ -112,12 +119,13 @@ def debug_print(msg): else: # No template for this size - check if there's a legacy Settings template # This matches the editor's fallback behavior - settings_html = (self.settings.invoice_pdf_template_html or '').strip() - settings_css = (self.settings.invoice_pdf_template_css or '').strip() - + settings_html = (self.settings.invoice_pdf_template_html or "").strip() + settings_css = (self.settings.invoice_pdf_template_css or "").strip() + if settings_html or settings_css: # Use legacy Settings template, but ensure page size is correct from types import SimpleNamespace + legacy_template = SimpleNamespace() legacy_template.page_size = self.page_size legacy_template.template_html = settings_html @@ -127,10 +135,10 @@ def debug_print(msg): # No templates at all, use default generation html_content = self._generate_html() css_content = self._generate_css() - + # Configure fonts font_config = FontConfiguration() - + # Create PDF (avoid passing unexpected args to PDF class) base_url = None try: @@ -140,114 +148,127 @@ def debug_print(msg): # Final verification: ensure CSS has correct @page size using the same logic as update_page_size_in_css # This is critical - WeasyPrint uses @page rules from stylesheets import re + debug_print("[DEBUG] Final CSS verification - checking @page rules") - + # Check what @page size is in CSS before update - if '@page' in css_content: - page_size_match = re.search(r'@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)', css_content, re.IGNORECASE | re.DOTALL) + if "@page" in css_content: + page_size_match = re.search( + r"@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)", css_content, re.IGNORECASE | re.DOTALL + ) if page_size_match: found_size = page_size_match.group(1).strip() debug_print(f"[DEBUG] Found @page size in CSS: '{found_size}' (expected: '{self.page_size}')") else: debug_print("[DEBUG] @page rule exists but no size property found") - + # Re-apply update_page_size_in_css to ensure correctness (this handles nested braces properly) - if '@page' in css_content: + if "@page" in css_content: # Use the same function that's defined in _render_from_custom_template # But we need to call it here, so define a helper def final_update_page_size(css_text): """Final update of @page size - same logic as update_page_size_in_css""" - page_match = re.search(r'@page\s*\{', css_text, re.IGNORECASE | re.MULTILINE) + page_match = re.search(r"@page\s*\{", css_text, re.IGNORECASE | re.MULTILINE) if page_match: start_pos = page_match.start() brace_count = 0 end_pos = len(css_text) for i in range(page_match.end() - 1, len(css_text)): - if css_text[i] == '{': + if css_text[i] == "{": brace_count += 1 - elif css_text[i] == '}': + elif css_text[i] == "}": brace_count -= 1 if brace_count == 0: end_pos = i + 1 break page_block = css_text[start_pos:end_pos] - if re.search(r'size\s*:', page_block, re.IGNORECASE): + if re.search(r"size\s*:", page_block, re.IGNORECASE): updated_block = re.sub( - r'size\s*:\s*[^;}\n]+', - f'size: {self.page_size};', + r"size\s*:\s*[^;}\n]+", + f"size: {self.page_size};", page_block, - flags=re.IGNORECASE | re.MULTILINE + flags=re.IGNORECASE | re.MULTILINE, ) css_text = css_text[:start_pos] + updated_block + css_text[end_pos:] debug_print("[DEBUG] Updated @page size in CSS block") return css_text + css_content = final_update_page_size(css_content) - + # Verify after update - page_size_match_after = re.search(r'@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)', css_content, re.IGNORECASE | re.DOTALL) + page_size_match_after = re.search( + r"@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)", css_content, re.IGNORECASE | re.DOTALL + ) if page_size_match_after: found_size_after = page_size_match_after.group(1).strip() debug_print(f"[DEBUG] After update - @page size in CSS: '{found_size_after}'") if found_size_after != self.page_size: - debug_print(f"[ERROR] @page size still incorrect! Expected '{self.page_size}', found '{found_size_after}'") + debug_print( + f"[ERROR] @page size still incorrect! Expected '{self.page_size}', found '{found_size_after}'" + ) else: debug_print(f"[DEBUG] ✓ @page size is correct: '{found_size_after}'") - + debug_print(f"[DEBUG] Generating PDF with WeasyPrint") debug_print(f"[DEBUG] - HTML length: {len(html_content)}") debug_print(f"[DEBUG] - CSS length: {len(css_content)}") - + # Log final CSS @page rule that will be used - if '@page' in css_content: - page_rule_match = re.search(r'(@page\s*\{[^}]*\})', css_content, re.IGNORECASE | re.DOTALL) + if "@page" in css_content: + page_rule_match = re.search(r"(@page\s*\{[^}]*\})", css_content, re.IGNORECASE | re.DOTALL) if page_rule_match: final_page_rule = page_rule_match.group(1)[:150] # First 150 chars debug_print(f"[DEBUG] Final @page rule being used: {final_page_rule}") - + html_doc = HTML(string=html_content, base_url=base_url) css_doc = CSS(string=css_content, font_config=font_config) pdf_bytes = html_doc.write_pdf(stylesheets=[css_doc], font_config=font_config) - + debug_print(f"[DEBUG] PDF generated successfully - size: {len(pdf_bytes)} bytes") debug_print(f"{'='*80}\n") - + return pdf_bytes + def _render_from_custom_template(self, template=None): """Render HTML and CSS from custom templates stored in database, with fallback to default template.""" # Define debug_print for this method scope import sys + def debug_print(msg): """Print debug message to stdout with immediate flush for Docker visibility""" print(msg, file=sys.stdout, flush=True) print(msg, file=sys.stderr, flush=True) - + if template: # Ensure template matches the selected page size - if hasattr(template, 'page_size') and template.page_size != self.page_size: + if hasattr(template, "page_size") and template.page_size != self.page_size: # Template doesn't match - this shouldn't happen, but handle it # Get the correct template from app.models import InvoicePDFTemplate + correct_template = InvoicePDFTemplate.query.filter_by(page_size=self.page_size).first() if correct_template: template = correct_template else: # Couldn't find correct template - use default generation instead raise ValueError(f"Template for page size {self.page_size} not found") - + # Don't strip - preserve exact content as saved (whitespace might be important) - html_template = template.template_html or '' - css_template = template.template_css or '' + html_template = template.template_html or "" + css_template = template.template_css or "" else: # No template provided - this should not happen in normal flow # If it does, we can't proceed without a template raise ValueError(f"No template provided for page size {self.page_size}. This is a bug.") - html = '' + html = "" + def update_page_size_in_css(css_text): """Update @page size property to match selected page size""" import re + # Find @page rule and update its size property # Handle nested @bottom-center rules by finding matching braces - page_match = re.search(r'@page\s*\{', css_text, re.IGNORECASE | re.MULTILINE) + page_match = re.search(r"@page\s*\{", css_text, re.IGNORECASE | re.MULTILINE) if page_match: start_pos = page_match.start() # Find matching closing brace, accounting for nested braces @@ -255,82 +276,86 @@ def update_page_size_in_css(css_text): pos = page_match.end() - 1 end_pos = len(css_text) for i in range(page_match.end() - 1, len(css_text)): - if css_text[i] == '{': + if css_text[i] == "{": brace_count += 1 - elif css_text[i] == '}': + elif css_text[i] == "}": brace_count -= 1 if brace_count == 0: end_pos = i + 1 break - + page_block = css_text[start_pos:end_pos] - + # Replace or add size property - if re.search(r'size\s*:', page_block, re.IGNORECASE): + if re.search(r"size\s*:", page_block, re.IGNORECASE): # Replace existing size property - handle any whitespace and values # Match: size: A4; or size: A4 ; or size:Letter; etc. # Use a more robust pattern that handles various formats updated_block = re.sub( - r'size\s*:\s*[^;}\n]+', - f'size: {self.page_size}', + r"size\s*:\s*[^;}\n]+", + f"size: {self.page_size}", page_block, - flags=re.IGNORECASE | re.MULTILINE + flags=re.IGNORECASE | re.MULTILINE, ) css_text = css_text[:start_pos] + updated_block + css_text[end_pos:] else: # Add size property after @page { updated_block = re.sub( - r'(@page\s*\{)', - r'\1\n size: ' + self.page_size + r';', + r"(@page\s*\{)", + r"\1\n size: " + self.page_size + r";", page_block, count=1, - flags=re.IGNORECASE + flags=re.IGNORECASE, ) css_text = css_text[:start_pos] + updated_block + css_text[end_pos:] else: # Add @page rule at the beginning if it doesn't exist - new_page_rule = f'@page {{\n size: {self.page_size};\n margin: 2cm;\n }}\n\n' + new_page_rule = ( + f"@page {{\n size: {self.page_size};\n margin: 2cm;\n }}\n\n" + ) css_text = new_page_rule + css_text - + return css_text - + def update_page_size_in_html(html_text): """Update @page size property in HTML's inline )' + style_pattern = r"(]*>)(.*?)()" if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL): html_text = re.sub(style_pattern, update_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL) - + return html_text - + def remove_page_rule_from_html(html_text): """Remove @page rules from HTML inline styles to avoid conflicts with separate CSS""" import re + def remove_from_style_tag(match): style_content = match.group(2) # Remove @page rule from style content # Need to handle nested @bottom-center rules properly # Match @page { ... } including any nested rules brace_count = 0 - page_pattern = r'@page\s*\{' + page_pattern = r"@page\s*\{" page_match = re.search(page_pattern, style_content, re.IGNORECASE) - + if page_match: start = page_match.start() # Find matching closing brace pos = page_match.end() - 1 end = len(style_content) for i in range(page_match.end() - 1, len(style_content)): - if style_content[i] == '{': + if style_content[i] == "{": brace_count += 1 - elif style_content[i] == '}': + elif style_content[i] == "}": brace_count -= 1 if brace_count == 0: end = i + 1 @@ -338,48 +363,50 @@ def remove_from_style_tag(match): # Remove the @page rule style_content = style_content[:start] + style_content[end:] # Clean up any double newlines or extra whitespace - style_content = re.sub(r'\n\s*\n', '\n', style_content) - - return f'{match.group(1)}{style_content}{match.group(3)}' - + style_content = re.sub(r"\n\s*\n", "\n", style_content) + + return f"{match.group(1)}{style_content}{match.group(3)}" + # Match )' + style_pattern = r"(]*>)(.*?)()" if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL): html_text = re.sub(style_pattern, remove_from_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL) - + return html_text - + # Handle CSS: When both HTML (with inline styles) and separate CSS exist, # extract inline styles, merge with separate CSS, and remove from HTML to avoid conflicts import re - css_to_use = '' + + css_to_use = "" html_inline_styles_extracted = False - + # Extract inline styles from HTML if present - extracted_inline_css = '' - if html_template and '', html_template, re.IGNORECASE | re.DOTALL) + extracted_inline_css = "" + if html_template and "", html_template, re.IGNORECASE | re.DOTALL) if style_match: extracted_inline_css = style_match.group(1) html_inline_styles_extracted = True - + if css_template and css_template.strip(): # Use separate CSS template - this is the authoritative source # Don't merge with inline styles - the CSS template should contain everything needed # (Editor saves both HTML with styles AND CSS, but CSS is the clean source) debug_print(f"[DEBUG] Using separate CSS template (length: {len(css_template)})") - + # Check @page size before update import re - before_match = re.search(r'@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)', css_template, re.IGNORECASE | re.DOTALL) + + before_match = re.search(r"@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)", css_template, re.IGNORECASE | re.DOTALL) if before_match: before_size = before_match.group(1).strip() debug_print(f"[DEBUG] CSS template @page size BEFORE update: '{before_size}'") - + css_to_use = update_page_size_in_css(css_template) - + # Check @page size after update - after_match = re.search(r'@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)', css_to_use, re.IGNORECASE | re.DOTALL) + after_match = re.search(r"@page\s*\{[^}]*?size\s*:\s*([^;}\n]+)", css_to_use, re.IGNORECASE | re.DOTALL) if after_match: after_size = after_match.group(1).strip() debug_print(f"[DEBUG] CSS template @page size AFTER update: '{after_size}'") @@ -394,34 +421,35 @@ def remove_from_style_tag(match): # No CSS provided, use default try: from flask import render_template as _render_tpl - css_to_use = _render_tpl('invoices/pdf_styles_default.css') + + css_to_use = _render_tpl("invoices/pdf_styles_default.css") css_to_use = update_page_size_in_css(css_to_use) except Exception: css_to_use = self._generate_css() - + # Ensure @page rule has correct size - this is critical for PDF generation css = css_to_use # Import helper functions for template from app.utils.template_filters import get_logo_base64 from babel.dates import format_date as babel_format_date - - def format_date(value, format='medium'): + + def format_date(value, format="medium"): """Format date for template""" if babel_format_date: return babel_format_date(value, format=format) - return value.strftime('%Y-%m-%d') if value else '' - + return value.strftime("%Y-%m-%d") if value else "" + def format_money(value): """Format money for template""" try: return f"{float(value):,.2f}" except Exception: return str(value) - + # Convert lazy='dynamic' relationships to lists for template rendering # This ensures {% for item in invoice.items %} works correctly try: - if hasattr(self.invoice.items, 'all'): + if hasattr(self.invoice.items, "all"): # It's a SQLAlchemy Query object - need to call .all() invoice_items = self.invoice.items.all() else: @@ -429,9 +457,9 @@ def format_money(value): invoice_items = list(self.invoice.items) if self.invoice.items else [] except Exception: invoice_items = [] - + try: - if hasattr(self.invoice.extra_goods, 'all'): + if hasattr(self.invoice.extra_goods, "all"): # It's a SQLAlchemy Query object - need to call .all() invoice_extra_goods = self.invoice.extra_goods.all() else: @@ -439,13 +467,14 @@ def format_money(value): invoice_extra_goods = list(self.invoice.extra_goods) if self.invoice.extra_goods else [] except Exception: invoice_extra_goods = [] - + # Create a wrapper object that has the converted lists from types import SimpleNamespace + invoice_data = SimpleNamespace() # Copy all attributes from original invoice for attr in dir(self.invoice): - if not attr.startswith('_'): + if not attr.startswith("_"): try: setattr(invoice_data, attr, getattr(self.invoice, attr)) except Exception: @@ -453,76 +482,86 @@ def format_money(value): # Override with converted lists invoice_data.items = invoice_items invoice_data.extra_goods = invoice_extra_goods - + # Convert expenses from Query to list try: - if hasattr(self.invoice, 'expenses') and hasattr(self.invoice.expenses, 'all'): + if hasattr(self.invoice, "expenses") and hasattr(self.invoice.expenses, "all"): invoice_expenses = self.invoice.expenses.all() else: invoice_expenses = list(self.invoice.expenses) if self.invoice.expenses else [] except Exception: invoice_expenses = [] invoice_data.expenses = invoice_expenses - + try: # Render using Flask's Jinja environment to include app filters and _() if html_template: from flask import render_template_string + # When we have separate CSS, remove @page rules from HTML inline styles # to ensure the separate CSS @page rule is used (WeasyPrint uses first @page it finds) # Keep all other inline styles (like positioning) to preserve layout if html_inline_styles_extracted and css_template: # Check if HTML has @page rules import re - html_page_rules = re.findall(r'@page\s*\{[^}]*\}', html_template, re.IGNORECASE | re.DOTALL) + + html_page_rules = re.findall(r"@page\s*\{[^}]*\}", html_template, re.IGNORECASE | re.DOTALL) if html_page_rules: - debug_print(f"[DEBUG] Found {len(html_page_rules)} @page rule(s) in HTML inline styles - removing them") + debug_print( + f"[DEBUG] Found {len(html_page_rules)} @page rule(s) in HTML inline styles - removing them" + ) for i, rule in enumerate(html_page_rules): debug_print(f"[DEBUG] HTML @page rule {i+1}: {rule[:80]}") - + # Remove @page rules from HTML inline styles (keep everything else) html_template_updated = remove_page_rule_from_html(html_template) debug_print("[DEBUG] Removed @page rules from HTML inline styles") else: # No separate CSS or no inline styles - use template as-is or update inline @page - if html_template and '