diff --git a/.flake8 b/.flake8 index 5ecc730e..c5c36703 100644 --- a/.flake8 +++ b/.flake8 @@ -1,14 +1,8 @@ [flake8] max-line-length = 120 -extend-ignore = E203, W503 -exclude = - .git, - __pycache__, - venv, - .venv, - build, - dist, - htmlcov, - app/static/vendor - - +max-complexity = 10 +# C901=complexity, E501=line length, F401=unused import, E402=import not at top, +# F541=f-string no placeholder, E712=comparison to True/False, F841=unused variable, E741=ambiguous name, +# F811=redefinition (e.g. import inside function), W293=blank line whitespace, E203=whitespace before ':' +extend-ignore = C901, E501, F401, E402, F541, E712, F841, E741, F811, W293, E203 +exclude = .git,__pycache__,migrations,.venv,venv,build,dist diff --git a/.github/workflows/cd-development.yml b/.github/workflows/cd-development.yml index 50dfd2ad..b9e3e44a 100644 --- a/.github/workflows/cd-development.yml +++ b/.github/workflows/cd-development.yml @@ -157,6 +157,8 @@ jobs: type=raw,value=develop type=raw,value=dev-{{date 'YYYYMMDD-HHmmss'}} type=sha,prefix=dev-,format=short + labels: | + org.opencontainers.image.description=Self-hosted time tracking web application for projects, clients, and reports. - name: Determine version id: version diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 9ec3efb0..2b5797ad 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -365,6 +365,8 @@ jobs: type=semver,pattern={{major}},value=${{ needs.determine-version.outputs.version }} type=raw,value=latest,enable={{is_default_branch}} type=raw,value=stable,enable=${{ needs.determine-version.outputs.is_prerelease == 'false' }} + labels: | + org.opencontainers.image.description=Self-hosted time tracking web application for projects, clients, and reports. - name: Inject analytics configuration from GitHub Secrets env: @@ -1017,16 +1019,16 @@ jobs: working-directory: mobile run: flutter pub get + - name: Generate iOS platform files + working-directory: mobile + run: flutter create --platforms=ios . + - name: Generate launcher icons working-directory: mobile run: | dart run flutter_launcher_icons dart run flutter_launcher_icons -f flutter_launcher_icons_ios.yaml - - name: Generate iOS platform files - working-directory: mobile - run: flutter create --platforms=ios . - - name: Configure iOS project for device build without code signing working-directory: mobile run: | diff --git a/.github/workflows/migration-check.yml b/.github/workflows/migration-check.yml index 1d5fc224..9a57fab2 100644 --- a/.github/workflows/migration-check.yml +++ b/.github/workflows/migration-check.yml @@ -135,21 +135,76 @@ jobs: FLASK_APP: app.py FLASK_ENV: testing run: | + set -e echo "๐Ÿ”„ Testing migration rollback safety..." - # Get current migration - CURRENT_MIGRATION=$(flask db current) + # Get current migration (trim whitespace and optional "(head)" suffix) + CURRENT_MIGRATION=$(flask db current | sed 's/ (head)$//' | tr -d '[:space:]') echo "Current migration: $CURRENT_MIGRATION" - if [ -n "$CURRENT_MIGRATION" ] && [ "$CURRENT_MIGRATION" != "None" ]; then - # Generic rollback test: downgrade one revision, then upgrade back to head - echo "Testing migration rollback (downgrade -1, upgrade head)..." - flask db downgrade -1 - flask db upgrade head - echo "โœ… Migration rollback test passed" - else + if [ -z "$CURRENT_MIGRATION" ] || [ "$CURRENT_MIGRATION" = "None" ]; then echo "โ„น๏ธ No migrations to test rollback on" + exit 0 + fi + + # Resolve parent revision via Alembic (avoids ambiguous "downgrade -1" with merge revisions) + ROLLBACK_RESULT=$(python <<'PYEOF' + import os + import sys + from app import create_app, db + from alembic.config import Config + from alembic.script import ScriptDirectory + + app = create_app() + with app.app_context(): + r = db.session.execute(db.text("SELECT version_num FROM alembic_version")) + rows = r.fetchall() + if not rows: + print("SKIP") + sys.exit(0) + current_rev = rows[0][0] + config = Config(os.path.join(os.getcwd(), "migrations", "alembic.ini")) + script = ScriptDirectory.from_config(config) + rev = script.get_revision(current_rev) + if rev is None: + print("SKIP") + sys.exit(0) + down = rev.down_revision + if down is None: + print("SKIP") + elif isinstance(down, tuple): + print("SKIP") + else: + print("PARENT:" + down) + PYEOF + ) + + if [ "$ROLLBACK_RESULT" = "SKIP" ]; then + echo "โ„น๏ธ At base or merge revision โ€” skipping downgrade, verifying upgrade head..." + if ! flask db upgrade head; then + echo "โŒ Rollback test failed: upgrade head failed after skip" + exit 1 + fi + echo "โœ… Migration rollback test passed (downgrade skipped, upgrade head OK)" + exit 0 + fi + + PARENT_REV="${ROLLBACK_RESULT#PARENT:}" + if [ -z "$PARENT_REV" ]; then + echo "โŒ Rollback test failed: could not resolve parent revision" + exit 1 + fi + + echo "Testing migration rollback (downgrade to $PARENT_REV, then upgrade head)..." + if ! flask db downgrade "$PARENT_REV"; then + echo "โŒ Rollback test failed: downgrade to $PARENT_REV failed" + exit 1 + fi + if ! flask db upgrade head; then + echo "โŒ Rollback test failed: upgrade head failed after downgrade" + exit 1 fi + echo "โœ… Migration rollback test passed" - name: Test migration with sample data if: steps.migration_check.outputs.migration_changes == 'true' diff --git a/.gitignore b/.gitignore index ed7326d0..de361c3f 100644 --- a/.gitignore +++ b/.gitignore @@ -143,6 +143,7 @@ backups/ # IDE .vscode/ .idea/ +.cursor/ *.swp *.swo *~ @@ -167,16 +168,15 @@ bandit-report.json safety-report.json migration_report.md -# Coverage reports -.coverage.* -coverage/ -htmlcov/ -.coverage -coverage.xml - # Test output .testmondata +# Install / build logs +install_log.txt + +# Benchmark output +.benchmarks/ + # SSL Certificates (generated by mkcert) nginx/ssl/*.pem nginx/ssl/*.key @@ -243,9 +243,6 @@ mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock.lock mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock.lock.lock mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock.lock.lock.lock -mobile/.android/ - - ## License Files diff --git a/BUILD.md b/BUILD.md deleted file mode 100644 index f5533f0b..00000000 --- a/BUILD.md +++ /dev/null @@ -1,118 +0,0 @@ -# Building TimeTracker Mobile and Desktop Apps - -## Quick Start - -### Build Everything (All Platforms) - -**Linux/macOS:** -```bash -chmod +x scripts/build-all.sh -./scripts/build-all.sh -``` - -**Windows:** -```batch -scripts\build-all.bat -``` - -## Available Build Scripts - -### Main Build Scripts - -1. **build-all.sh / build-all.bat** - Build both mobile and desktop apps - - Automatically detects platform - - Supports flags: `--mobile-only`, `--desktop-only`, `--android-only`, `--ios-only`, `--windows-only`, `--linux-only`, `--macos-only` - -2. **build-mobile.sh / build-mobile.bat** - Build mobile app only - - Supports: `android`, `ios`, `all` - -3. **build-desktop.sh / build-desktop.bat** - Build desktop app only - - Supports: `win`, `mac`, `linux`, `all`, `current` - -## Usage Examples - -### Build Everything -```bash -# Linux/macOS -./scripts/build-all.sh - -# Windows -scripts\build-all.bat -``` - -### Build Mobile Only -```bash -# Linux/macOS -./scripts/build-all.sh --mobile-only -# Or -./scripts/build-mobile.sh android - -# Windows -scripts\build-all.bat --mobile-only -# Or -scripts\build-mobile.bat android -``` - -### Build Desktop Only -```bash -# Linux/macOS -./scripts/build-all.sh --desktop-only -# Or -./scripts/build-desktop.sh current - -# Windows -scripts\build-all.bat --desktop-only -# Or -scripts\build-desktop.bat win -``` - -### Build Specific Platform -```bash -# Android only -./scripts/build-all.sh --android-only - -# iOS only (macOS) -./scripts/build-all.sh --ios-only - -# Windows desktop only -scripts\build-all.bat --windows-only - -# Linux desktop only -./scripts/build-all.sh --linux-only -``` - -## Prerequisites - -### Mobile App -- Flutter SDK 3.0+ -- Android SDK (for Android) -- Xcode (for iOS, macOS only) -- **App icon:** Launcher icons are generated at the start of each mobile build from `mobile/assets/icon/app_icon.png`. That PNG can be exported once from `app/static/images/timetracker-logo-icon.svg` (1024ร—1024), or created by running `scripts/generate-mobile-icon.bat` / `scripts/generate-mobile-icon.sh` (requires ImageMagick, Inkscape, or Python with Pillow). - -### Desktop App -- Node.js 18+ -- npm -- Platform build tools - -## Build Outputs - -### Mobile App -- **Android APK**: `mobile/build/app/outputs/flutter-apk/app-release.apk` -- **Android AAB**: `mobile/build/app/outputs/bundle/release/app-release.aab` -- **iOS**: `mobile/build/ios/iphoneos/Runner.app` - -### Desktop App -- **Windows**: `desktop/dist/*.exe` -- **macOS**: `desktop/dist/*.dmg` -- **Linux**: `desktop/dist/*.AppImage` and `desktop/dist/*.deb` - -## Troubleshooting - -- **Mobile launcher icon shows Android default:** Run icon generation and do a full clean build: from `mobile/` run `flutter clean`, `flutter pub get`, `dart run flutter_launcher_icons`, then build again. The build scripts run icon generation automatically; if you built without them, run the above once. -- **Icon should match the web app:** Export `app/static/images/timetracker-logo-icon.svg` to 1024ร—1024 PNG at `mobile/assets/icon/app_icon.png` (see `mobile/assets/icon/README.md`), then run `dart run flutter_launcher_icons` and rebuild. - -See `scripts/README-BUILD.md` for detailed troubleshooting guide. - -## CI/CD - -These scripts are designed to work in CI/CD environments (GitHub Actions workflows are also available in `.github/workflows/`). diff --git a/BUILD_QUICK_FIX.md b/BUILD_QUICK_FIX.md deleted file mode 100644 index 8ea19da1..00000000 --- a/BUILD_QUICK_FIX.md +++ /dev/null @@ -1,66 +0,0 @@ -# Quick Fix for Windows Build Issues - -## Try the Simplified Build Script - -If `build-desktop.bat` stops working, use the simplified version: - -```cmd -scripts\build-desktop-simple.bat -``` - -This version: -- Uses simpler logic (no delayed expansion issues) -- Has better debug output -- More reliable on Windows - -## If That Doesn't Work - -### Step 1: Manual Build - -```cmd -cd desktop -npm install -npx electron-builder --win -``` - -### Step 2: Check What's Missing - -Run the test script: -```cmd -scripts\test-build-desktop.bat -``` - -This will show exactly what's wrong. - -### Step 3: Common Fixes - -1. **Exclude node_modules from OneDrive** - - Right-click `desktop\node_modules` - - Choose "Always keep on this device" - -2. **Reinstall Dependencies** - ```cmd - cd desktop - rmdir /s /q node_modules - del package-lock.json - npm install - ``` - -3. **Fix Script Issues** - ```cmd - scripts\fix-windows-build.bat - ``` - -## Debugging - -If the script stops after "npm version", check: -- Is node_modules missing or corrupted? -- Are there OneDrive file locks? -- Does `npx` work? Try: `npx --version` - ---- - -**Last Resort:** Use the PowerShell script: -```powershell -.\scripts\build-desktop-windows.ps1 -``` diff --git a/BUILD_WINDOWS.md b/BUILD_WINDOWS.md deleted file mode 100644 index ff460c88..00000000 --- a/BUILD_WINDOWS.md +++ /dev/null @@ -1,52 +0,0 @@ -# Building on Windows - Quick Guide - -## ๐Ÿš€ Quick Start - -**Use the Windows-native build script:** - -```cmd -scripts\build-desktop-windows.bat -``` - -Or in PowerShell: -```powershell -.\scripts\build-desktop-windows.ps1 -``` - -## โš ๏ธ Important: OneDrive Issues - -If your project is in OneDrive, you **MUST** exclude `node_modules` from sync: - -1. Right-click `desktop\node_modules` folder -2. Choose **"Always keep on this device"** -3. Or exclude it from OneDrive sync entirely - -This prevents 90% of Windows build issues! - -## ๐Ÿ”ง If Build Fails - -### Step 1: Fix npm Issues -```cmd -scripts\fix-windows-build.bat -``` - -### Step 2: Generate Icons (if missing) -```cmd -cd desktop -npm install sharp -cd .. -node scripts\generate-icons.js -``` - -### Step 3: Build Again -```cmd -scripts\build-desktop-windows.bat -``` - -## ๐Ÿ“‹ Full Documentation - -See [README_WINDOWS_BUILD.md](README_WINDOWS_BUILD.md) for complete troubleshooting guide. - ---- - -**TL;DR:** Use `scripts\build-desktop-windows.bat` and exclude `node_modules` from OneDrive! diff --git a/Dockerfile b/Dockerfile index 856cf88c..bc808dd3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,6 +30,7 @@ ENV APP_VERSION=${APP_VERSION} ENV TZ=Europe/Rome # Support visibility: if donate_hide_public.pem is in project root it is copied to /app; set path so app finds it (override in compose if needed) ENV DONATE_HIDE_PUBLIC_KEY_FILE=/app/donate_hide_public.pem +LABEL org.opencontainers.image.description="Self-hosted time tracking web application for projects, clients, and reports." # Install all system dependencies in a single layer RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ diff --git a/INSTALLATION.md b/INSTALLATION.md index 3317cbe0..13fd9242 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -57,7 +57,7 @@ To try TimeTracker without PostgreSQL: ```bash git clone https://github.com/drytrix/TimeTracker.git cd TimeTracker -docker-compose -f docker-compose.local-test.yml up --build +docker-compose -f docker/docker-compose.local-test.yml up --build ``` Then open **http://localhost:8080**. No `.env` is required for this compose file. SQLite is for evaluation only; use PostgreSQL for production. diff --git a/README.md b/README.md index 38c3650a..ab0a98ba 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ TimeTracker is built with modern, reliable technologies: - **flake8** โ€” Linting - **coverage** โ€” Test coverage analysis -**๐Ÿ“– Documentation:** [Architecture overview](ARCHITECTURE.md) ยท [Project Structure](docs/development/PROJECT_STRUCTURE.md) ยท [UI Guidelines](docs/UI_GUIDELINES.md) +**๐Ÿ“– Documentation:** [Architecture overview](docs/ARCHITECTURE.md) ยท [Project Structure](docs/development/PROJECT_STRUCTURE.md) ยท [UI Guidelines](docs/UI_GUIDELINES.md) --- @@ -87,7 +87,7 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh > **๐Ÿ“‹ For complete release history, see [CHANGELOG.md](CHANGELOG.md)** **Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history. -- ๐Ÿ“ฑ **Native Mobile & Desktop Apps** โ€” Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](BUILD.md), [Docs](docs/mobile-desktop-apps/README.md)) +- ๐Ÿ“ฑ **Native Mobile & Desktop Apps** โ€” Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](docs/build/BUILD.md), [Docs](docs/mobile-desktop-apps/README.md)) - ๐Ÿ“‹ **Project Analysis & Documentation** โ€” Comprehensive project analysis and documentation updates - ๐Ÿ”ง **Version Consistency** โ€” Fixed version inconsistencies across documentation files @@ -288,7 +288,7 @@ TimeTracker includes **130+ features** across 13 major categories. See the [Comp - **Docker Ready** โ€” Deploy in minutes with Docker Compose - **Database Flexibility** โ€” PostgreSQL for production, SQLite for testing - **Responsive Design** โ€” Mobile-first design works perfectly on desktop, tablet, and mobile -- **Native Mobile & Desktop Apps** โ€” Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](BUILD.md), [Docs](docs/mobile-desktop-apps/README.md)) +- **Native Mobile & Desktop Apps** โ€” Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](docs/build/BUILD.md), [Docs](docs/mobile-desktop-apps/README.md)) - **Real-time Sync** โ€” WebSocket support for live updates across devices - **Automatic Backups** โ€” Scheduled database backups (configurable) - **Progressive Web App (PWA)** โ€” Install as mobile app with offline support and background sync @@ -466,7 +466,7 @@ git clone https://github.com/drytrix/TimeTracker.git cd TimeTracker # 2. Start with the local test configuration (uses SQLite, no PostgreSQL) -docker-compose -f docker-compose.local-test.yml up --build +docker-compose -f docker/docker-compose.local-test.yml up --build # 3. Access at http://localhost:8080 ``` @@ -592,10 +592,10 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory. See **For Developers:** - **[Contributing](CONTRIBUTING.md)** โ€” How to contribute (quick link) - **[Contributing Guidelines (full)](docs/development/CONTRIBUTING.md)** โ€” Setup, standards, PR process -- **[Development Guide](DEVELOPMENT.md)** โ€” Run locally, test, release process -- **[Architecture](ARCHITECTURE.md)** โ€” System overview and design +- **[Development Guide](docs/DEVELOPMENT.md)** โ€” Run locally, test, release process +- **[Architecture](docs/ARCHITECTURE.md)** โ€” System overview and design - **[Project Structure](docs/development/PROJECT_STRUCTURE.md)** โ€” Codebase layout -- **[API Documentation](API.md)** โ€” API quick reference ยท [Full REST API](docs/api/REST_API.md) +- **[API Documentation](docs/API.md)** โ€” API quick reference ยท [Full REST API](docs/api/REST_API.md) - **[Database Migrations](migrations/README.md)** โ€” Schema management - **[CI/CD Documentation](docs/cicd/CI_CD_DOCUMENTATION.md)** โ€” Build and deployment @@ -629,7 +629,7 @@ Comprehensive documentation is available in the [`docs/`](docs/) directory. See **Integrations & Apps:** - **[Mobile & Desktop Apps](docs/mobile-desktop-apps/README.md)** โ€” Flutter mobile and Electron desktop apps -- **[Build Guide (Mobile & Desktop)](BUILD.md)** โ€” Build scripts for Android, iOS, Windows, macOS, Linux +- **[Build Guide (Mobile & Desktop)](docs/build/BUILD.md)** โ€” Build scripts for Android, iOS, Windows, macOS, Linux - **[Peppol & ZugFerd e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md)** โ€” Peppol sending and ZugFerd/Factur-X PDF embedding (EN 16931) - **[API Documentation](docs/api/REST_API.md)** โ€” REST API reference - **[API Token Scopes](docs/api/API_TOKEN_SCOPES.md)** โ€” Token permissions @@ -697,7 +697,7 @@ docker-compose up -d #### Option 2: Use Pre-built Images ```bash # Use the remote compose file with published images -docker-compose -f docker-compose.remote.yml up -d +docker-compose -f docker/docker-compose.remote.yml up -d ``` > **โš ๏ธ Security Note:** Always set a unique `SECRET_KEY` in production! See [CSRF Configuration](docs/admin/security/CSRF_CONFIGURATION.md) for details. @@ -721,14 +721,16 @@ docker-compose up -d #### Manual HTTPS with mkcert (No Browser Warnings) ```bash # Use mkcert for locally-trusted certificates -docker-compose -f docker-compose.https-mkcert.yml up -d +docker-compose -f docker/docker-compose.https-mkcert.yml up -d ``` -**๐Ÿ“– See [HTTPS Setup Guide](docs/admin/security/README_HTTPS.md) for detailed instructions** +**๐Ÿ“– See [HTTPS Setup Guide](docs/admin/security/README_HTTPS.md) for detailed instructions.** HTTPS helper scripts live in `scripts/` (e.g. from project root: `bash scripts/setup-https-mkcert.sh`, `bash scripts/start-https.sh`). ### Monitoring & Analytics ```bash +# Alternate compose files (local-test, remote, analytics, https) are in docker/; use -f docker/docker-compose.xxx.yml + # Deploy with full monitoring stack (Prometheus, Grafana, Loki) -docker-compose up -d +docker-compose -f docker-compose.yml -f docker/docker-compose.analytics.yml --profile monitoring up -d # Grafana: http://localhost:3000 # Prometheus: http://localhost:9090 ``` @@ -928,7 +930,7 @@ This starts: #### ๐Ÿ“ฑ Native Mobile & Desktop Apps - โœ… **Flutter Mobile App** โ€” Native iOS and Android apps with time tracking, calendar view, offline sync, and API token authentication - โœ… **Electron Desktop App** โ€” Windows, macOS, and Linux desktop app with system tray, time tracking, and offline support -- โœ… **Build Scripts** โ€” Cross-platform build scripts for mobile and desktop ([BUILD.md](BUILD.md)) +- โœ… **Build Scripts** โ€” Cross-platform build scripts for mobile and desktop ([BUILD.md](docs/build/BUILD.md)) #### ๐Ÿ—๏ธ Architecture & Performance - โœ… **Service Layer Migration** โ€” Routes migrated to service layer pattern @@ -959,7 +961,7 @@ We welcome contributions! Whether it's: 2. **Set Up Development Environment** ```bash # Use SQLite for quick local testing - docker-compose -f docker-compose.local-test.yml up -d + docker-compose -f docker/docker-compose.local-test.yml up -d ``` 3. **Make Your Changes** @@ -974,7 +976,7 @@ We welcome contributions! Whether it's: **๐Ÿ“– [CONTRIBUTING.md](CONTRIBUTING.md)** โ€” Quick contributing overview **๐Ÿ“– [Full Contributing Guidelines](docs/development/CONTRIBUTING.md)** โ€” Setup, standards, PR process -**๐Ÿ“– [DEVELOPMENT.md](DEVELOPMENT.md)** โ€” Run locally, tests, releases +**๐Ÿ“– [DEVELOPMENT.md](docs/DEVELOPMENT.md)** โ€” Run locally, tests, releases **๐Ÿ“– [Local Testing with SQLite](docs/development/LOCAL_TESTING_WITH_SQLITE.md)** โ€” Docker SQLite setup --- diff --git a/app/__init__.py b/app/__init__.py index 62d4d5bb..dd489820 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,30 +1,31 @@ +import logging import os +import re import tempfile -import logging -import uuid import time +import uuid from datetime import timedelta -from flask import Flask, request, session, redirect, url_for, flash, jsonify, g -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate -from flask_login import LoginManager -from flask_socketio import SocketIO +from urllib.parse import urlparse + +import posthog +import sentry_sdk +from authlib.integrations.flask_client import OAuth from dotenv import load_dotenv +from flask import Flask, flash, g, jsonify, redirect, request, session, url_for from flask_babel import Babel, _ -from flask_wtf.csrf import CSRFProtect, CSRFError from flask_limiter import Limiter from flask_limiter.util import get_remote_address -from authlib.integrations.flask_client import OAuth -import re -from urllib.parse import urlparse -from werkzeug.middleware.proxy_fix import ProxyFix -from werkzeug.http import parse_options_header +from flask_login import LoginManager +from flask_migrate import Migrate +from flask_socketio import SocketIO +from flask_sqlalchemy import SQLAlchemy +from flask_wtf.csrf import CSRFError, CSRFProtect +from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest from pythonjsonlogger import jsonlogger -from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST -import sentry_sdk from sentry_sdk.integrations.flask import FlaskIntegration -import posthog from sqlalchemy.pool import StaticPool +from werkzeug.http import parse_options_header +from werkzeug.middleware.proxy_fix import ProxyFix # Load environment variables load_dotenv() @@ -237,7 +238,10 @@ def create_app(config=None): app.config.update(config) # Production safety: refuse to start with default SECRET_KEY - if app.config.get("FLASK_ENV") == "production" and app.config.get("SECRET_KEY") == "dev-secret-key-change-in-production": + if ( + app.config.get("FLASK_ENV") == "production" + and app.config.get("SECRET_KEY") == "dev-secret-key-change-in-production" + ): raise ValueError( "SECRET_KEY must be set explicitly in production. " "Set the SECRET_KEY environment variable to a secure random value." @@ -282,7 +286,7 @@ def create_app(config=None): # BUT only if DATABASE_URL was not explicitly set to SQLite current_url = app.config.get("SQLALCHEMY_DATABASE_URI", "") explicit_database_url = os.getenv("DATABASE_URL", "") - + # Only auto-switch to PostgreSQL if: # 1. Not in testing mode # 2. Current URL is SQLite @@ -319,13 +323,14 @@ def create_app(config=None): except Exception: pass return app - + # Initialize audit logging - register event listeners AFTER db.init_app() # Flask-SQLAlchemy uses its own session class, so we need to register with it - from app.utils import audit from sqlalchemy import event from sqlalchemy.orm import Session - + + from app.utils import audit + # Register with generic SQLAlchemy Session (catches all Session instances) event.listen(Session, "before_flush", audit.receive_before_flush) event.listen(Session, "after_flush", audit.receive_after_flush) @@ -334,7 +339,7 @@ def create_app(config=None): # Flask-SQLAlchemy creates sessions from a sessionmaker, so we register with that try: # Get the sessionmaker from Flask-SQLAlchemy - if hasattr(db, 'session') and hasattr(db.session, 'registry'): + if hasattr(db, "session") and hasattr(db.session, "registry"): sessionmaker = db.session.registry() if sessionmaker: # Register with the session class that the sessionmaker creates @@ -342,13 +347,16 @@ def create_app(config=None): if session_class: event.listen(session_class, "before_flush", audit.receive_before_flush) event.listen(session_class, "after_flush", audit.receive_after_flush) - logger.info(f"Registered audit logging with Flask-SQLAlchemy session class: {session_class.__name__}") + logger.info( + f"Registered audit logging with Flask-SQLAlchemy session class: {session_class.__name__}" + ) except Exception as e: logger.debug(f"Could not register with Flask-SQLAlchemy sessionmaker: {e}") # Register with SignallingSession (Flask-SQLAlchemy 2.x) try: from flask_sqlalchemy import SignallingSession + event.listen(SignallingSession, "before_flush", audit.receive_before_flush) event.listen(SignallingSession, "after_flush", audit.receive_after_flush) logger.info("Registered audit logging with Flask-SQLAlchemy SignallingSession") @@ -477,12 +485,13 @@ def _normalize_locale(locale_code): # Ensure gettext helpers available in Jinja try: - from flask_babel import gettext as _gettext, ngettext as _ngettext + from flask_babel import gettext as _gettext + from flask_babel import ngettext as _ngettext app.jinja_env.globals.update(_=_gettext, ngettext=_ngettext) except Exception: pass - + # Add Python built-ins that are useful in templates app.jinja_env.globals.update(getattr=getattr) @@ -529,6 +538,7 @@ def _ensure_user_authenticated_in_tests(): try: if app.config.get("TESTING"): from flask_login import current_user, login_user + from app.utils.db import safe_query if not getattr(current_user, "is_authenticated", False): @@ -659,12 +669,13 @@ def record_metrics_and_log(response): # Setup logging (including JSON logging) from app.utils.setup_logging import setup_logging as _setup_logging + _setup_logging(app) # Enable query logging in development mode if app.config.get("FLASK_DEBUG") or app.config.get("TESTING"): try: - from app.utils.query_logging import enable_query_logging, enable_query_counting + from app.utils.query_logging import enable_query_counting, enable_query_logging enable_query_logging(app, slow_query_threshold=0.1) enable_query_counting(app) @@ -738,7 +749,7 @@ def record_metrics_and_log(response): 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") - + # Check for debug mode in production - this is a security risk flask_debug = app.config.get("FLASK_DEBUG", False) if flask_debug or app.debug: @@ -991,22 +1002,24 @@ def get_csrf_token(): pass return resp - # Register blueprints (centralized in blueprint_registry) from app.blueprint_registry import register_all_blueprints + register_all_blueprints(app, logger) # Register integration connectors try: from app.integrations import registry + logger.info("Integration connectors registered") except Exception as e: logger.warning(f"Could not register integration connectors: {e}") # Exempt API blueprints from CSRF protection (requires api_bp, api_v1_bp, api_docs_bp) from app.routes.api import api_bp - from app.routes.api_v1 import api_v1_bp from app.routes.api_docs import api_docs_bp + from app.routes.api_v1 import api_v1_bp + # Only if CSRF is enabled (JSON API uses token authentication, not CSRF tokens) if app.config.get("WTF_CSRF_ENABLED"): csrf.exempt(api_bp) @@ -1015,6 +1028,7 @@ def get_csrf_token(): # Initialize OIDC IP cache from app.utils.oidc_metadata import initialize_ip_cache + ip_cache_ttl = int(app.config.get("OIDC_IP_CACHE_TTL", 300)) initialize_ip_cache(ip_cache_ttl) @@ -1032,7 +1046,7 @@ def get_csrf_token(): if issuer and client_id and client_secret: # Try to fetch metadata first using our utility with better DNS handling from app.utils.oidc_metadata import fetch_oidc_metadata - + # Get retry configuration from environment max_retries = int(app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3)) retry_delay = int(app.config.get("OIDC_METADATA_RETRY_DELAY", 2)) @@ -1040,7 +1054,7 @@ def get_csrf_token(): dns_strategy = app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto") use_ip_directly = app.config.get("OIDC_USE_IP_DIRECTLY", True) use_docker_internal = app.config.get("OIDC_USE_DOCKER_INTERNAL", True) - + metadata, metadata_error, diagnostics = fetch_oidc_metadata( issuer, max_retries=max_retries, @@ -1051,7 +1065,7 @@ def get_csrf_token(): use_ip_directly=use_ip_directly, use_docker_internal=use_docker_internal, ) - + # Log diagnostics if available if diagnostics: app.logger.info( @@ -1060,7 +1074,7 @@ def get_csrf_token(): diagnostics.get("dns_resolution", {}).get("ip_address", "none"), len(diagnostics.get("strategies_tried", [])), ) - + if metadata: # Successfully fetched metadata - register with it try: @@ -1103,7 +1117,11 @@ def get_csrf_token(): except Exception as e: error_msg = str(e) # Check if it's a DNS resolution error - if "NameResolutionError" in error_msg or "Failed to resolve" in error_msg or "[Errno -2]" in error_msg: + if ( + "NameResolutionError" in error_msg + or "Failed to resolve" in error_msg + or "[Errno -2]" in error_msg + ): # Store config for lazy loading in login route app.config["OIDC_ISSUER_FOR_LAZY_LOAD"] = issuer app.config["OIDC_CLIENT_ID_FOR_LAZY_LOAD"] = client_id @@ -1138,22 +1156,23 @@ def get_csrf_token(): "AUTH_METHOD is %s but OIDC envs are incomplete; OIDC login will not work", auth_method, ) - + # Schedule background metadata refresh if enabled refresh_interval = int(app.config.get("OIDC_METADATA_REFRESH_INTERVAL", 3600)) if refresh_interval > 0 and issuer and client_id and client_secret: + def refresh_oidc_metadata(): """Background task to refresh OIDC metadata""" try: from app.utils.oidc_metadata import fetch_oidc_metadata - + max_retries = int(app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3)) retry_delay = int(app.config.get("OIDC_METADATA_RETRY_DELAY", 2)) timeout = int(app.config.get("OIDC_METADATA_FETCH_TIMEOUT", 10)) dns_strategy = app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto") use_ip_directly = app.config.get("OIDC_USE_IP_DIRECTLY", True) use_docker_internal = app.config.get("OIDC_USE_DOCKER_INTERNAL", True) - + app.logger.info("Background OIDC metadata refresh started for issuer %s", issuer) metadata, metadata_error, diagnostics = fetch_oidc_metadata( issuer, @@ -1165,12 +1184,16 @@ def refresh_oidc_metadata(): use_ip_directly=use_ip_directly, use_docker_internal=use_docker_internal, ) - + if metadata: app.logger.info( "Background OIDC metadata refresh successful (issuer: %s, strategy: %s)", metadata.get("issuer"), - diagnostics.get("dns_resolution", {}).get("strategy", "unknown") if diagnostics else "unknown", + ( + diagnostics.get("dns_resolution", {}).get("strategy", "unknown") + if diagnostics + else "unknown" + ), ) else: app.logger.warning( @@ -1179,7 +1202,7 @@ def refresh_oidc_metadata(): ) except Exception as e: app.logger.error("Error in background OIDC metadata refresh: %s", str(e)) - + # Schedule the refresh task try: scheduler.add_job( @@ -1190,9 +1213,7 @@ def refresh_oidc_metadata(): replace_existing=True, max_instances=1, ) - app.logger.info( - "Scheduled OIDC metadata refresh every %d seconds", refresh_interval - ) + app.logger.info("Scheduled OIDC metadata refresh every %d seconds", refresh_interval) except Exception as e: app.logger.warning("Failed to schedule OIDC metadata refresh: %s", str(e)) @@ -1261,22 +1282,14 @@ def _promote_admin_users_on_request(): def initialize_database(): try: # Import models to ensure they are registered - from app.models import ( - User, - Project, - TimeEntry, - Task, - Settings, - TaskActivity, - Comment, - Issue, - ) + from app.models import Comment, Issue, Project, Settings, Task, TaskActivity, TimeEntry, User # Create database tables db.create_all() # Check and migrate Task Management tables if needed - from app.utils.legacy_migrations import migrate_task_management_tables, migrate_issues_table + from app.utils.legacy_migrations import migrate_issues_table, migrate_task_management_tables + migrate_task_management_tables() migrate_issues_table() @@ -1329,22 +1342,14 @@ def init_database(app): with app.app_context(): try: # Import models to ensure they are registered - from app.models import ( - User, - Project, - TimeEntry, - Task, - Settings, - TaskActivity, - Comment, - Issue, - ) + from app.models import Comment, Issue, Project, Settings, Task, TaskActivity, TimeEntry, User # Create database tables db.create_all() # Check and migrate Task Management tables if needed - from app.utils.legacy_migrations import migrate_task_management_tables, migrate_issues_table + from app.utils.legacy_migrations import migrate_issues_table, migrate_task_management_tables + migrate_task_management_tables() migrate_issues_table() diff --git a/app/blueprint_registry.py b/app/blueprint_registry.py index 8c99b4a7..51c11124 100644 --- a/app/blueprint_registry.py +++ b/app/blueprint_registry.py @@ -6,65 +6,66 @@ def register_all_blueprints(app, logger=None): """Import and register all route blueprints. Optional blueprints are wrapped in try/except.""" - from app.routes.auth import auth_bp - from app.routes.main import main_bp - from app.routes.projects import projects_bp - from app.routes.timer import timer_bp - from app.routes.reports import reports_bp from app.routes.admin import admin_bp + from app.routes.analytics import analytics_bp from app.routes.api import api_bp + from app.routes.api_docs import api_docs_bp, swaggerui_blueprint from app.routes.api_v1 import api_v1_bp - from app.routes.api_v1_time_entries import api_v1_time_entries_bp - from app.routes.api_v1_projects import api_v1_projects_bp - from app.routes.api_v1_tasks import api_v1_tasks_bp from app.routes.api_v1_clients import api_v1_clients_bp - from app.routes.api_v1_invoices import api_v1_invoices_bp - from app.routes.api_v1_expenses import api_v1_expenses_bp - from app.routes.api_v1_payments import api_v1_payments_bp - from app.routes.api_v1_mileage import api_v1_mileage_bp + from app.routes.api_v1_contacts import api_v1_contacts_bp from app.routes.api_v1_deals import api_v1_deals_bp + from app.routes.api_v1_expenses import api_v1_expenses_bp + from app.routes.api_v1_invoices import api_v1_invoices_bp from app.routes.api_v1_leads import api_v1_leads_bp - from app.routes.api_v1_contacts import api_v1_contacts_bp - from app.routes.api_docs import api_docs_bp, swaggerui_blueprint - from app.routes.analytics import analytics_bp - from app.routes.tasks import tasks_bp - from app.routes.issues import issues_bp - from app.routes.invoices import invoices_bp - from app.routes.recurring_invoices import recurring_invoices_bp - from app.routes.payments import payments_bp - from app.routes.clients import clients_bp + from app.routes.api_v1_mileage import api_v1_mileage_bp + from app.routes.api_v1_payments import api_v1_payments_bp + from app.routes.api_v1_projects import api_v1_projects_bp + from app.routes.api_v1_tasks import api_v1_tasks_bp + from app.routes.api_v1_time_entries import api_v1_time_entries_bp + from app.routes.auth import auth_bp + from app.routes.budget_alerts import budget_alerts_bp + from app.routes.calendar import calendar_bp from app.routes.client_notes import client_notes_bp + from app.routes.client_portal import client_portal_bp + from app.routes.clients import clients_bp from app.routes.comments import comments_bp - from app.routes.kanban import kanban_bp - from app.routes.setup import setup_bp - from app.routes.user import user_bp - from app.routes.time_entry_templates import time_entry_templates_bp - from app.routes.saved_filters import saved_filters_bp - from app.routes.settings import settings_bp - from app.routes.weekly_goals import weekly_goals_bp - from app.routes.expenses import expenses_bp - from app.routes.permissions import permissions_bp - from app.routes.calendar import calendar_bp + from app.routes.contacts import contacts_bp + from app.routes.custom_field_definitions import custom_field_definitions_bp + from app.routes.custom_reports import custom_reports_bp + from app.routes.deals import deals_bp from app.routes.expense_categories import expense_categories_bp - from app.routes.mileage import mileage_bp - from app.routes.per_diem import per_diem_bp - from app.routes.budget_alerts import budget_alerts_bp + from app.routes.expenses import expenses_bp from app.routes.import_export import import_export_bp - from app.routes.webhooks import webhooks_bp - from app.routes.client_portal import client_portal_bp - from app.routes.quotes import quotes_bp from app.routes.inventory import inventory_bp - from app.routes.contacts import contacts_bp - from app.routes.deals import deals_bp - from app.routes.leads import leads_bp + from app.routes.invoices import invoices_bp + from app.routes.issues import issues_bp + from app.routes.kanban import kanban_bp from app.routes.kiosk import kiosk_bp + from app.routes.leads import leads_bp from app.routes.link_templates import link_templates_bp - from app.routes.custom_field_definitions import custom_field_definitions_bp - from app.routes.custom_reports import custom_reports_bp + from app.routes.main import main_bp + from app.routes.mileage import mileage_bp + from app.routes.payments import payments_bp + from app.routes.per_diem import per_diem_bp + from app.routes.permissions import permissions_bp + from app.routes.projects import projects_bp + from app.routes.quotes import quotes_bp + from app.routes.recurring_invoices import recurring_invoices_bp + from app.routes.reports import reports_bp from app.routes.salesman_reports import salesman_reports_bp + from app.routes.saved_filters import saved_filters_bp + from app.routes.settings import settings_bp + from app.routes.setup import setup_bp + from app.routes.tasks import tasks_bp + from app.routes.time_entry_templates import time_entry_templates_bp + from app.routes.timer import timer_bp + from app.routes.user import user_bp + from app.routes.webhooks import webhooks_bp + from app.routes.weekly_goals import weekly_goals_bp try: from app.routes.audit_logs import audit_logs_bp + app.register_blueprint(audit_logs_bp) except Exception as e: if logger: diff --git a/app/config.py b/app/config.py index dcf65676..86853e4a 100644 --- a/app/config.py +++ b/app/config.py @@ -73,7 +73,7 @@ class Config: 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 metadata fetch configuration (for DNS resolution issues) OIDC_METADATA_FETCH_TIMEOUT = int(os.getenv("OIDC_METADATA_FETCH_TIMEOUT", 10)) # seconds OIDC_METADATA_RETRY_ATTEMPTS = int(os.getenv("OIDC_METADATA_RETRY_ATTEMPTS", 3)) # number of retries @@ -129,9 +129,7 @@ class Config: DONATE_HIDE_UNLOCK_SECRET = _donate_secret # Support & Purchase Key page URL (for links to purchase a key to hide donate UI) - SUPPORT_PURCHASE_URL = os.getenv( - "SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html" - ).strip() + SUPPORT_PURCHASE_URL = os.getenv("SUPPORT_PURCHASE_URL", "https://timetracker.drytrix.com/support.html").strip() # Backup settings BACKUP_RETENTION_DAYS = int(os.getenv("BACKUP_RETENTION_DAYS", 30)) @@ -271,6 +269,7 @@ def __init__(self): # Enforce that SECRET_KEY is set via environment in production if self._SECRET_KEY_IS_DEFAULT: import warnings + warnings.warn( "SECURITY WARNING: SECRET_KEY is using the default development value. " "Set the SECRET_KEY environment variable to a secure random value in production.", @@ -279,9 +278,9 @@ def __init__(self): ) if len(self.SECRET_KEY) < 32: import warnings + warnings.warn( - "SECURITY WARNING: SECRET_KEY is too short. " - "Use a key of at least 32 characters for production.", + "SECURITY WARNING: SECRET_KEY is too short. " "Use a key of at least 32 characters for production.", RuntimeWarning, stacklevel=2, ) diff --git a/app/config/__init__.py b/app/config/__init__.py index 8e317575..df418ee5 100644 --- a/app/config/__init__.py +++ b/app/config/__init__.py @@ -6,10 +6,11 @@ - Analytics configuration for telemetry """ +import os + # Import Flask configuration classes from parent config.py # We need to import from the parent app module to avoid circular imports import sys -import os # Import analytics configuration from app.config.analytics_defaults import get_analytics_config, has_analytics_configured @@ -19,7 +20,7 @@ # So we need to import it properly try: # Try to import from a renamed file if it exists - from app.flask_config import Config, ProductionConfig, DevelopmentConfig, TestingConfig + from app.flask_config import Config, DevelopmentConfig, ProductionConfig, TestingConfig except ImportError: # If the file wasn't renamed, we need to import it differently # Add parent to path temporarily to import the shadowed config.py diff --git a/app/config/analytics_defaults.py b/app/config/analytics_defaults.py index 36492c30..36f58ab7 100644 --- a/app/config/analytics_defaults.py +++ b/app/config/analytics_defaults.py @@ -48,20 +48,20 @@ def get_version_from_setup(): # Try multiple possible paths to setup.py possible_paths = [] - + # Path 1: Relative to this file (app/config/analytics_defaults.py -> setup.py) try: base_path = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) possible_paths.append(os.path.join(base_path, "setup.py")) except Exception: pass - + # Path 2: Current working directory try: possible_paths.append(os.path.join(os.getcwd(), "setup.py")) except Exception: pass - + # Path 3: From environment variable (if set) try: project_root = os.getenv("PROJECT_ROOT") or os.getenv("APP_ROOT") @@ -69,7 +69,7 @@ def get_version_from_setup(): possible_paths.append(os.path.join(project_root, "setup.py")) except Exception: pass - + # Path 4: Try to find setup.py by walking up from current file try: current = os.path.dirname(__file__) diff --git a/app/integrations/activitywatch.py b/app/integrations/activitywatch.py index 79c2d78b..77a3c57e 100644 --- a/app/integrations/activitywatch.py +++ b/app/integrations/activitywatch.py @@ -176,9 +176,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: } else: selected = [ - b - for b in all_bucket_ids - if b.startswith("aw-watcher-window_") or b.startswith("aw-watcher-web_") + b for b in all_bucket_ids if b.startswith("aw-watcher-window_") or b.startswith("aw-watcher-web_") ] if not selected: diff --git a/app/integrations/asana.py b/app/integrations/asana.py index b73ccdda..4257e5d2 100644 --- a/app/integrations/asana.py +++ b/app/integrations/asana.py @@ -3,11 +3,13 @@ Sync tasks and projects with Asana. """ -from typing import Dict, Any, Optional, List +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, List, Optional + import requests -import os + +from app.integrations.base import BaseConnector class AsanaConnector(BaseConnector): @@ -90,6 +92,7 @@ def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, An except Exception as e: # Log error but don't fail - user info is optional import logging + logger = logging.getLogger(__name__) logger.debug(f"Could not fetch Asana user info: {e}") @@ -158,8 +161,8 @@ def test_connection(self) -> Dict[str, Any]: def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync tasks and projects with Asana.""" - from app.models import Task, Project from app import db + from app.models import Project, Task try: headers = {"Authorization": f"Bearer {self.get_access_token()}"} diff --git a/app/integrations/base.py b/app/integrations/base.py index 8a613898..797ecae5 100644 --- a/app/integrations/base.py +++ b/app/integrations/base.py @@ -3,8 +3,8 @@ """ from abc import ABC, abstractmethod -from typing import Dict, Any, Optional, List from datetime import datetime +from typing import Any, Dict, List, Optional class BaseConnector(ABC): @@ -120,7 +120,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Default implementation - override in subclasses return {"success": False, "message": "Sync not implemented for this connector"} - def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None) -> Dict[str, Any]: + def handle_webhook( + self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None + ) -> Dict[str, Any]: """ Handle incoming webhook from the service. @@ -183,7 +185,7 @@ def get_config_schema(self) -> Dict[str, Any]: "sync_interval": "manual", "sync_direction": "provider_to_timetracker", "sync_items": [], - } + }, } def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: @@ -198,7 +200,7 @@ def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """ schema = self.get_config_schema() errors = [] - + # Check required fields required_fields = schema.get("required", []) for field_name in required_fields: @@ -210,16 +212,16 @@ def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: field_label = field.get("label", field_name) break errors.append(f"{field_label} is required") - + # Validate field types and constraints for field in schema.get("fields", []): field_name = field.get("name") if field_name not in config: continue - + value = config[field_name] field_type = field.get("type", "string") - + # Type validation if field_type == "number" and value is not None: try: @@ -238,6 +240,7 @@ def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: elif field_type == "url" and value: try: from urllib.parse import urlparse + parsed = urlparse(value) if not parsed.scheme or not parsed.netloc: errors.append(f"{field.get('label', field_name)} must be a valid URL") @@ -246,51 +249,54 @@ def validate_config(self, config: Dict[str, Any]) -> Dict[str, Any]: elif field_type == "json" and value: try: import json + if isinstance(value, str): json.loads(value) except json.JSONDecodeError: errors.append(f"{field.get('label', field_name)} must be valid JSON") - + return {"valid": len(errors) == 0, "errors": errors} - + def get_sync_settings(self) -> Dict[str, Any]: """ Get current sync settings from integration config. - + Returns: Dict with sync settings """ if not self.integration or not self.integration.config: schema = self.get_config_schema() return schema.get("sync_settings", {}) - + config = self.integration.config schema = self.get_config_schema() default_sync_settings = schema.get("sync_settings", {}) - + return { "enabled": config.get("sync_enabled", default_sync_settings.get("enabled", True)), "auto_sync": config.get("auto_sync", default_sync_settings.get("auto_sync", False)), "sync_interval": config.get("sync_interval", default_sync_settings.get("sync_interval", "manual")), - "sync_direction": config.get("sync_direction", default_sync_settings.get("sync_direction", "provider_to_timetracker")), + "sync_direction": config.get( + "sync_direction", default_sync_settings.get("sync_direction", "provider_to_timetracker") + ), "sync_items": config.get("sync_items", default_sync_settings.get("sync_items", [])), } - + def get_field_mappings(self) -> Dict[str, str]: """ Get field mappings for data translation. - + Returns: Dict mapping provider fields to TimeTracker fields """ if not self.integration or not self.integration.config: return {} return self.integration.config.get("field_mappings", {}) - + def get_status_mappings(self) -> Dict[str, str]: """ Get status mappings for data translation. - + Returns: Dict mapping provider statuses to TimeTracker statuses """ diff --git a/app/integrations/caldav_calendar.py b/app/integrations/caldav_calendar.py index b4d65f14..08316b5d 100644 --- a/app/integrations/caldav_calendar.py +++ b/app/integrations/caldav_calendar.py @@ -20,18 +20,17 @@ from __future__ import annotations +import xml.etree.ElementTree as ET from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urljoin, urlparse -import xml.etree.ElementTree as ET import requests from icalendar import Calendar from app.integrations.base import BaseConnector -from app.utils.timezone import get_timezone_obj, local_to_utc, utc_to_local, now_in_app_timezone - +from app.utils.timezone import get_timezone_obj, local_to_utc, now_in_app_timezone, utc_to_local DAV_NS = "DAV:" CALDAV_NS = "urn:ietf:params:xml:ns:caldav" @@ -46,7 +45,7 @@ def _ensure_trailing_slash(u: str) -> str: if not u: return u # Don't add slash if URL has query string or fragment - if '?' in u or '#' in u: + if "?" in u or "#" in u: return u return u if u.endswith("/") else (u + "/") @@ -115,11 +114,17 @@ def _request(self, method: str, url: str, *, headers: Optional[Dict[str, str]] = ) return resp except requests.exceptions.SSLError as e: - raise ValueError(f"SSL certificate verification failed. If using a self-signed certificate, disable SSL verification in settings. Error: {str(e)}") from e + raise ValueError( + f"SSL certificate verification failed. If using a self-signed certificate, disable SSL verification in settings. Error: {str(e)}" + ) from e except requests.exceptions.Timeout as e: - raise ValueError(f"Request timeout after {self.timeout} seconds. The server may be slow or unreachable.") from e + raise ValueError( + f"Request timeout after {self.timeout} seconds. The server may be slow or unreachable." + ) from e except requests.exceptions.ConnectionError as e: - raise ValueError(f"Connection error: {str(e)}. Please check the server URL and network connectivity.") from e + raise ValueError( + f"Connection error: {str(e)}. Please check the server URL and network connectivity." + ) from e def _propfind(self, url: str, xml_body: str, depth: str = "0") -> ET.Element: resp = self._request( @@ -190,7 +195,9 @@ def discover_calendars(self, server_url: str) -> List[CalDAVCalendar]: root = self._propfind(principal_url, body, depth="0") home_href = self._find_href(root, [(_ns("calendar-home-set", CALDAV_NS),)]) if not home_href: - raise ValueError("Could not discover calendar-home-set from CalDAV server. The server may not support CalDAV or the credentials may be incorrect.") + raise ValueError( + "Could not discover calendar-home-set from CalDAV server. The server may not support CalDAV or the credentials may be incorrect." + ) except ValueError: raise except Exception as e: @@ -240,12 +247,12 @@ def fetch_events(self, calendar_url: str, time_min_utc: datetime, time_max_utc: """ Fetch VEVENTs within a time range using a calendar-query REPORT. Returns a list of dicts with uid, summary, description, start, end, href. - + Note: Recurring events (RRULE) are not expanded - only instances that fall within the time range are returned if the server supports it. """ calendar_url = _ensure_trailing_slash(calendar_url) - + # Validate time range if time_max_utc <= time_min_utc: raise ValueError("time_max_utc must be after time_min_utc") @@ -261,8 +268,8 @@ def fetch_events(self, calendar_url: str, time_min_utc: datetime, time_max_utc: "" "" "" - "" - "" + '' + '' f'' "" "" @@ -274,16 +281,25 @@ def fetch_events(self, calendar_url: str, time_min_utc: datetime, time_max_utc: events: List[Dict[str, Any]] = [] response_count = len(root.findall(_ns("response", DAV_NS))) - + import logging + logger = logging.getLogger(__name__) logger.info(f"CalDAV query returned {response_count} responses for time range {start_utc} to {end_utc}") logger.info(f" Query time range: {time_min_utc} to {time_max_utc}") logger.info(f" CalDAV format: {start_utc} to {end_utc}") - + skipped_count = 0 - skipped_reasons = {"no_href": 0, "no_caldata": 0, "parse_error": 0, "no_uid": 0, "no_dtstart": 0, "all_day": 0, "no_dtend": 0} - + skipped_reasons = { + "no_href": 0, + "no_caldata": 0, + "parse_error": 0, + "no_uid": 0, + "no_dtstart": 0, + "all_day": 0, + "no_dtend": 0, + } + for resp in root.findall(_ns("response", DAV_NS)): href_el = resp.find(_ns("href", DAV_NS)) href = href_el.text.strip() if href_el is not None and href_el.text else None @@ -306,6 +322,7 @@ def fetch_events(self, calendar_url: str, time_min_utc: datetime, time_max_utc: except Exception as e: # Log parsing errors but continue with other events import logging + logger = logging.getLogger(__name__) skipped_count += 1 skipped_reasons["parse_error"] += 1 @@ -350,7 +367,7 @@ def fetch_events(self, calendar_url: str, time_min_utc: datetime, time_max_utc: # Handle DTEND or DURATION dtend = comp.get("DTEND") duration = comp.get("DURATION") - + if dtend: end = dtend.dt if not isinstance(end, datetime): @@ -401,82 +418,86 @@ def fetch_events(self, calendar_url: str, time_min_utc: datetime, time_max_utc: logger.info(f"Skipped {skipped_count} events: {skipped_reasons}") return events - def create_or_update_event(self, calendar_url: str, event_uid: str, ical_content: str, event_href: Optional[str] = None) -> bool: + def create_or_update_event( + self, calendar_url: str, event_uid: str, ical_content: str, event_href: Optional[str] = None + ) -> bool: """ Create or update a calendar event using PUT request. - + Args: calendar_url: Calendar collection URL event_uid: Unique identifier for the event ical_content: iCalendar content (VCALENDAR with VEVENT) event_href: Optional existing event href for updates - + Returns: True if successful, False otherwise """ import logging + logger = logging.getLogger(__name__) - + calendar_url = _ensure_trailing_slash(calendar_url) # Use provided href if available, otherwise construct from UID if event_href: # If event_href is absolute, validate it matches calendar_url base, otherwise reconstruct - if event_href.startswith('http://') or event_href.startswith('https://'): + if event_href.startswith("http://") or event_href.startswith("https://"): # Parse both URLs to compare from urllib.parse import urlparse + href_parsed = urlparse(event_href) cal_parsed = urlparse(calendar_url) - + # If the href is from a different host/port, reconstruct using calendar_url base - if (href_parsed.scheme != cal_parsed.scheme or - href_parsed.netloc != cal_parsed.netloc): + if href_parsed.scheme != cal_parsed.scheme or href_parsed.netloc != cal_parsed.netloc: logger.warning(f"Event href {event_href} doesn't match calendar URL {calendar_url}, reconstructing") # Reconstruct using calendar_url base filename = f"{event_uid}.ics" - if calendar_url.endswith('/'): + if calendar_url.endswith("/"): event_url = calendar_url + filename else: - event_url = calendar_url + '/' + filename + event_url = calendar_url + "/" + filename else: event_url = event_href else: # Relative href - join with calendar_url base - event_url = urljoin(calendar_url, event_href.lstrip('/')) + event_url = urljoin(calendar_url, event_href.lstrip("/")) else: # Event URL is typically: calendar_url + event_uid + ".ics" # Use proper URL joining - ensure calendar_url ends with / and filename doesn't start with / filename = f"{event_uid}.ics" - if calendar_url.endswith('/'): + if calendar_url.endswith("/"): event_url = calendar_url + filename else: - event_url = calendar_url + '/' + filename - + event_url = calendar_url + "/" + filename + headers = { "Content-Type": "text/calendar; charset=utf-8", } - + try: logger.info(f"PUT request to {event_url} for event {event_uid} (calendar_url: {calendar_url})") logger.info(f" iCalendar content length: {len(ical_content)} bytes") logger.debug(f" iCalendar content preview: {ical_content[:200]}...") resp = self._request("PUT", event_url, headers=headers, data=ical_content) - + logger.info(f" Response status: {resp.status_code}") logger.debug(f" Response headers: {dict(resp.headers)}") - + # Handle redirects manually for PUT requests if resp.status_code in (301, 302, 303, 307, 308): - redirect_url = resp.headers.get('Location') + redirect_url = resp.headers.get("Location") if redirect_url: logger.info(f"Following redirect from {event_url} to {redirect_url}") # Make redirect URL absolute if it's relative - if not redirect_url.startswith('http'): + if not redirect_url.startswith("http"): from urllib.parse import urljoin, urlparse + parsed = urlparse(event_url) redirect_url = f"{parsed.scheme}://{parsed.netloc}{redirect_url}" resp = self._request("PUT", redirect_url, headers=headers, data=ical_content) logger.info(f" Redirect response status: {resp.status_code}") - + resp.raise_for_status() logger.info(f"Successfully created/updated event {event_uid} at {event_url} (status: {resp.status_code})") return True @@ -486,17 +507,19 @@ def create_or_update_event(self, calendar_url: str, event_uid: str, ical_content error_detail += f": {e.response.text[:500]}" logger.warning(f"HTTP error creating/updating CalDAV event {event_uid} at {event_url}: {error_detail}") logger.debug(f" Full response text: {e.response.text}") - + if e.response.status_code == 404: - logger.info(f"CalDAV event {event_uid} not found at {event_url}, attempting to create with standard URL") + logger.info( + f"CalDAV event {event_uid} not found at {event_url}, attempting to create with standard URL" + ) # Try creating with standard URL if custom href failed if event_href: # Try standard URL format filename = f"{event_uid}.ics" - if calendar_url.endswith('/'): + if calendar_url.endswith("/"): standard_url = calendar_url + filename else: - standard_url = calendar_url + '/' + filename + standard_url = calendar_url + "/" + filename if event_href != standard_url: try: logger.info(f"Trying standard URL: {standard_url}") @@ -507,11 +530,15 @@ def create_or_update_event(self, calendar_url: str, event_uid: str, ical_content return True except Exception as e2: logger.warning(f"Failed to create event at standard URL {standard_url}: {e2}") - if hasattr(e2, 'response') and e2.response: - logger.warning(f" Response status: {e2.response.status_code}, text: {e2.response.text[:200]}") + if hasattr(e2, "response") and e2.response: + logger.warning( + f" Response status: {e2.response.status_code}, text: {e2.response.text[:200]}" + ) return False elif e.response.status_code == 403: - logger.warning(f"Permission denied (403) when creating event {event_uid}. Check calendar write permissions.") + logger.warning( + f"Permission denied (403) when creating event {event_uid}. Check calendar write permissions." + ) logger.warning(f" Response: {e.response.text[:200]}") elif e.response.status_code == 401: logger.warning(f"Authentication failed (401) when creating event {event_uid}. Check credentials.") @@ -592,13 +619,16 @@ def test_connection(self) -> Dict[str, Any]: # Check if credentials exist if not self.credentials: return {"success": False, "message": "No credentials configured. Please set up username and password."} - + # Check if we have username and password try: username, password = self._get_basic_creds() except ValueError as e: - return {"success": False, "message": f"Missing credentials: {str(e)}. Please configure username and password."} - + return { + "success": False, + "message": f"Missing credentials: {str(e)}. Please configure username and password.", + } + cfg = self.integration.config or {} server_url = cfg.get("server_url") calendar_url = cfg.get("calendar_url") @@ -628,7 +658,11 @@ def test_connection(self) -> Dict[str, Any]: return { "success": True, - "message": f"Connected to CalDAV. Found {len(calendars)} calendars." if server_url else "Connected to CalDAV calendar.", + "message": ( + f"Connected to CalDAV. Found {len(calendars)} calendars." + if server_url + else "Connected to CalDAV calendar." + ), "calendars": [{"url": c.href, "name": c.name} for c in calendars], } except Exception as e: @@ -639,10 +673,11 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: Sync data between CalDAV and TimeTracker. MVP: calendar_to_time_tracker imports VEVENTs as TimeEntry records. """ - from app import db - from app.models import TimeEntry, Project, IntegrationExternalEventLink import logging + from app import db + from app.models import IntegrationExternalEventLink, Project, TimeEntry + logger = logging.getLogger(__name__) try: @@ -652,16 +687,19 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Check credentials if not self.credentials: return {"success": False, "message": "No credentials configured. Please set up username and password."} - + try: username, password = self._get_basic_creds() except ValueError as e: - return {"success": False, "message": f"Missing credentials: {str(e)}. Please configure username and password."} + return { + "success": False, + "message": f"Missing credentials: {str(e)}. Please configure username and password.", + } cfg = self.integration.config or {} calendar_url = cfg.get("calendar_url") server_url = cfg.get("server_url") - + if not calendar_url: if server_url: # Try to discover and use first calendar @@ -681,12 +719,21 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Save the discovered calendar URL db.session.commit() else: - return {"success": False, "message": "No calendars found on server. Please configure calendar URL manually."} + return { + "success": False, + "message": "No calendars found on server. Please configure calendar URL manually.", + } except Exception as e: logger.error(f"Could not discover calendars: {e}", exc_info=True) - return {"success": False, "message": f"Could not discover calendars: {str(e)}. Please configure calendar URL manually."} + return { + "success": False, + "message": f"Could not discover calendars: {str(e)}. Please configure calendar URL manually.", + } else: - return {"success": False, "message": "No calendar selected. Please configure calendar URL or server URL first."} + return { + "success": False, + "message": "No calendar selected. Please configure calendar URL or server URL first.", + } sync_direction = cfg.get("sync_direction", "calendar_to_time_tracker") default_project_id = cfg.get("default_project_id") @@ -698,7 +745,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if sync_direction in ("calendar_to_time_tracker", "bidirectional"): logger.info(f"Executing Calendarโ†’TimeTracker sync (sync_direction: {sync_direction})") - calendar_result = self._sync_calendar_to_time_tracker(cfg, calendar_url, sync_type, default_project_id, lookback_days) + calendar_result = self._sync_calendar_to_time_tracker( + cfg, calendar_url, sync_type, default_project_id, lookback_days + ) # If bidirectional, also do TimeTracker to Calendar sync if sync_direction == "bidirectional": logger.info(f"Executing TimeTrackerโ†’Calendar sync (bidirectional mode)") @@ -707,7 +756,8 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if calendar_result.get("success") and tracker_result.get("success"): return { "success": True, - "synced_items": calendar_result.get("synced_items", 0) + tracker_result.get("synced_items", 0), + "synced_items": calendar_result.get("synced_items", 0) + + tracker_result.get("synced_items", 0), "imported": calendar_result.get("imported", 0), "skipped": calendar_result.get("skipped", 0), "errors": calendar_result.get("errors", []) + tracker_result.get("errors", []), @@ -718,7 +768,10 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: elif tracker_result.get("success"): return tracker_result else: - return {"success": False, "message": f"Both sync directions failed. Calendarโ†’TimeTracker: {calendar_result.get('message')}, TimeTrackerโ†’Calendar: {tracker_result.get('message')}"} + return { + "success": False, + "message": f"Both sync directions failed. Calendarโ†’TimeTracker: {calendar_result.get('message')}, TimeTrackerโ†’Calendar: {tracker_result.get('message')}", + } logger.info(f"Calendarโ†’TimeTracker sync completed, returning result") return calendar_result @@ -726,7 +779,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if sync_direction == "time_tracker_to_calendar": logger.info(f"Executing TimeTrackerโ†’Calendar sync only (sync_direction: {sync_direction})") return self._sync_time_tracker_to_calendar(cfg, calendar_url, sync_type) - + logger.warning(f"Unknown sync direction: {sync_direction}") return {"success": False, "message": f"Unknown sync direction: {sync_direction}"} except Exception as e: @@ -746,16 +799,24 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: except Exception: pass return {"success": False, "message": f"Sync failed: {str(e)}"} - - def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, sync_type: str, default_project_id: Optional[int], lookback_days: int) -> Dict[str, Any]: + + def _sync_calendar_to_time_tracker( + self, + cfg: Dict[str, Any], + calendar_url: str, + sync_type: str, + default_project_id: Optional[int], + lookback_days: int, + ) -> Dict[str, Any]: """Sync calendar events from CalDAV to TimeTracker CalendarEvent records.""" + import logging + from app import db - from app.models import Project, CalendarEvent + from app.models import CalendarEvent, Project from app.models.integration_external_event_link import IntegrationExternalEventLink - import logging - + logger = logging.getLogger(__name__) - + # default_project_id is optional - if not provided, events will be imported without a project # Determine time window @@ -767,7 +828,7 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, time_min_utc = now_utc - timedelta(days=lookback_days) logger.info(f"Full sync: using lookback_days={lookback_days}, calculated time_min_utc={time_min_utc}") time_max_utc = now_utc + timedelta(days=7) - + logger.info(f"Time range calculation:") logger.info(f" now_utc: {now_utc}") logger.info(f" time_min_utc: {time_min_utc} (lookback: {lookback_days} days)") @@ -779,10 +840,12 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, try: events = client.fetch_events(calendar_url, time_min_utc, time_max_utc) logger.info(f"Fetched {len(events)} events from CalDAV calendar") - + # If no events found, try with an expanded time range (some servers are strict about time-range) if len(events) == 0: - logger.debug(f"No events found with initial time range, trying expanded range (extending by 1 day on each side)") + logger.debug( + f"No events found with initial time range, trying expanded range (extending by 1 day on each side)" + ) expanded_min = time_min_utc - timedelta(days=1) expanded_max = time_max_utc + timedelta(days=1) try: @@ -792,17 +855,20 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, if events: original_events = events events = [ - e for e in original_events - if (e["start"] <= time_max_utc and e["end"] >= time_min_utc) + e for e in original_events if (e["start"] <= time_max_utc and e["end"] >= time_min_utc) ] logger.info(f"Filtered to {len(events)} events within original time range") except Exception as e2: logger.debug(f"Expanded time range query also failed: {e2}") - + if len(events) == 0: - logger.warning(f"No events found in calendar {calendar_url} for time range {time_min_utc} to {time_max_utc}") + logger.warning( + f"No events found in calendar {calendar_url} for time range {time_min_utc} to {time_max_utc}" + ) else: - logger.debug(f"Event details (first 5): {[{'uid': e.get('uid', 'N/A')[:20], 'summary': e.get('summary', 'N/A')[:30], 'start': str(e.get('start', 'N/A')), 'end': str(e.get('end', 'N/A'))} for e in events[:5]]}") + logger.debug( + f"Event details (first 5): {[{'uid': e.get('uid', 'N/A')[:20], 'summary': e.get('summary', 'N/A')[:30], 'start': str(e.get('start', 'N/A')), 'end': str(e.get('end', 'N/A'))} for e in events[:5]]}" + ) except Exception as e: logger.error(f"Failed to fetch events from calendar: {e}", exc_info=True) return {"success": False, "message": f"Failed to fetch events from calendar: {str(e)}"} @@ -832,20 +898,20 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, for ev in events: try: uid = ev["uid"] - + # Check if this event was already imported # Since CalendarEvent doesn't have time_entry_id for IntegrationExternalEventLink, # we track imports by checking for CalendarEvent records with the [CalDAV: uid] marker in description existing_calendar_event = CalendarEvent.query.filter( CalendarEvent.user_id == self.integration.user_id, - CalendarEvent.description.like(f"%[CalDAV: {uid}]%") + CalendarEvent.description.like(f"%[CalDAV: {uid}]%"), ).first() - + # Also check link table in case it was previously imported as TimeEntry (for backward compatibility) existing_link = IntegrationExternalEventLink.query.filter_by( integration_id=self.integration.id, external_uid=uid ).first() - + if existing_calendar_event or existing_link: logger.debug(f"Event {uid} already imported (CalendarEvent or link exists), skipping") skipped += 1 @@ -854,13 +920,13 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, start_dt: datetime = ev["start"] end_dt: datetime = ev["end"] - + # Ensure both are timezone-aware UTC if start_dt.tzinfo is None: start_dt = start_dt.replace(tzinfo=timezone.utc) else: start_dt = start_dt.astimezone(timezone.utc) - + if end_dt.tzinfo is None: end_dt = end_dt.replace(tzinfo=timezone.utc) else: @@ -879,11 +945,11 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, project_id = None if default_project_id: project_id = int(default_project_id) - + title = (ev.get("summary") or "").strip() if not title: title = "Imported Calendar Event" - + # Try to match project by name in title (only if we have projects loaded) if not project_id: for p in projects: @@ -943,7 +1009,9 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, message = f"Imported {imported} events ({skipped} skipped: {skipped_reasons['already_imported']} duplicates, {skipped_reasons['invalid_time']} invalid time, {skipped_reasons['other']} other, {len(events)} total found)." logger.info(f"CalDAV sync completed: {message}") - logger.debug(f"Sync statistics: imported={imported}, skipped={skipped}, errors={len(errors)}, total_events={len(events)}") + logger.debug( + f"Sync statistics: imported={imported}, skipped={skipped}, errors={len(errors)}, total_events={len(events)}" + ) return { "success": True, @@ -953,19 +1021,20 @@ def _sync_calendar_to_time_tracker(self, cfg: Dict[str, Any], calendar_url: str, "errors": errors, "message": message, } - + def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, sync_type: str) -> Dict[str, Any]: """Sync TimeTracker time entries and calendar events to CalDAV calendar.""" + import logging + from app import db - from app.models import TimeEntry, Project, Task, CalendarEvent + from app.models import CalendarEvent, Project, Task, TimeEntry from app.models.integration_external_event_link import IntegrationExternalEventLink - import logging - + logger = logging.getLogger(__name__) - + lookback_days = int(cfg.get("lookback_days", 90)) lookahead_days = int(cfg.get("lookahead_days", 7)) - + now_utc = datetime.now(timezone.utc) if sync_type == "incremental" and self.integration.last_sync_at: time_min = self.integration.last_sync_at.replace(tzinfo=timezone.utc) @@ -974,18 +1043,20 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, time_min = now_utc - timedelta(days=lookback_days) logger.info(f"Full sync: using lookback_days={lookback_days}, calculated time_min={time_min}") time_max = now_utc + timedelta(days=lookahead_days) - + logger.info(f"Time range calculation for TimeTrackerโ†’Calendar sync:") logger.info(f" now_utc: {now_utc}") logger.info(f" time_min (UTC): {time_min} (lookback: {lookback_days} days)") logger.info(f" time_max (UTC): {time_max} (lookahead: {lookahead_days} days)") logger.info(f" Time range span: {(time_max - time_min).days} days") - + time_min_local = _to_local_naive(time_min) time_max_local = _to_local_naive(time_max) - - logger.info(f"Looking for time entries and calendar events for user {self.integration.user_id} between {time_min_local} and {time_max_local}") - + + logger.info( + f"Looking for time entries and calendar events for user {self.integration.user_id} between {time_min_local} and {time_max_local}" + ) + # Get time entries # First, check how many entries exist for this user in the time range (without end_time filter) all_entries_in_range = TimeEntry.query.filter( @@ -994,40 +1065,54 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, TimeEntry.start_time <= time_max_local, ).all() logger.info(f" Total time entries in time range (including without end_time): {len(all_entries_in_range)}") - + # Check how many have end_time entries_with_end_time = [e for e in all_entries_in_range if e.end_time is not None] logger.info(f" Time entries with end_time: {len(entries_with_end_time)}") entries_without_end_time = [e for e in all_entries_in_range if e.end_time is None] if entries_without_end_time: - logger.info(f" Time entries without end_time (will be skipped): {[e.id for e in entries_without_end_time]}") - - time_entries = TimeEntry.query.filter( - TimeEntry.user_id == self.integration.user_id, - TimeEntry.start_time >= time_min_local, - TimeEntry.start_time <= time_max_local, - TimeEntry.end_time.isnot(None), - ).order_by(TimeEntry.start_time).all() - + logger.info( + f" Time entries without end_time (will be skipped): {[e.id for e in entries_without_end_time]}" + ) + + time_entries = ( + TimeEntry.query.filter( + TimeEntry.user_id == self.integration.user_id, + TimeEntry.start_time >= time_min_local, + TimeEntry.start_time <= time_max_local, + TimeEntry.end_time.isnot(None), + ) + .order_by(TimeEntry.start_time) + .all() + ) + logger.info(f"Found {len(time_entries)} time entries to sync to CalDAV calendar (with end_time)") if time_entries: logger.info(f" Time entry IDs found: {[e.id for e in time_entries]}") for entry in time_entries: - logger.info(f" Time Entry {entry.id}: start={entry.start_time}, end={entry.end_time}, project_id={entry.project_id}, source={getattr(entry, 'source', 'unknown')}") - + logger.info( + f" Time Entry {entry.id}: start={entry.start_time}, end={entry.end_time}, project_id={entry.project_id}, source={getattr(entry, 'source', 'unknown')}" + ) + # Get calendar events - calendar_events = CalendarEvent.query.filter( - CalendarEvent.user_id == self.integration.user_id, - CalendarEvent.start_time >= time_min_local, - CalendarEvent.start_time <= time_max_local, - ).order_by(CalendarEvent.start_time).all() - + calendar_events = ( + CalendarEvent.query.filter( + CalendarEvent.user_id == self.integration.user_id, + CalendarEvent.start_time >= time_min_local, + CalendarEvent.start_time <= time_max_local, + ) + .order_by(CalendarEvent.start_time) + .all() + ) + logger.info(f"Found {len(calendar_events)} calendar events to sync to CalDAV calendar") if calendar_events: logger.info(f" Calendar event IDs found: {[e.id for e in calendar_events]}") for event in calendar_events: - logger.info(f" Calendar Event {event.id}: start={event.start_time}, end={event.end_time}, title={event.title}, all_day={event.all_day}") - + logger.info( + f" Calendar Event {event.id}: start={event.start_time}, end={event.end_time}, title={event.title}, all_day={event.all_day}" + ) + if not time_entries and not calendar_events: self.integration.last_sync_at = datetime.utcnow() self.integration.last_sync_status = "success" @@ -1039,54 +1124,65 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, "errors": [], "message": f"No time entries found in the specified time range ({time_min_local.date()} to {time_max_local.date()}).", } - + client = self._client() synced = 0 updated = 0 skipped_count = 0 errors: List[str] = [] - + total_items = len(time_entries) + len(calendar_events) - logger.info(f"Starting sync of {len(time_entries)} time entries and {len(calendar_events)} calendar events ({total_items} total) to CalDAV calendar") - + logger.info( + f"Starting sync of {len(time_entries)} time entries and {len(calendar_events)} calendar events ({total_items} total) to CalDAV calendar" + ) + # Sync time entries for time_entry in time_entries: try: event_uid = f"timetracker-{time_entry.id}@timetracker.local" - + existing_link = IntegrationExternalEventLink.query.filter_by( - integration_id=self.integration.id, - time_entry_id=time_entry.id + integration_id=self.integration.id, time_entry_id=time_entry.id ).first() - + # Log entry details for debugging - logger.info(f"Processing time entry {time_entry.id}: start={time_entry.start_time}, end={time_entry.end_time}, project_id={time_entry.project_id}, source={getattr(time_entry, 'source', 'unknown')}") + logger.info( + f"Processing time entry {time_entry.id}: start={time_entry.start_time}, end={time_entry.end_time}, project_id={time_entry.project_id}, source={getattr(time_entry, 'source', 'unknown')}" + ) if existing_link: - logger.info(f" Existing link found: external_uid={existing_link.external_uid}, external_href={existing_link.external_href}") - logger.info(f" Link external_uid starts with 'timetracker-': {existing_link.external_uid.startswith('timetracker-') if existing_link.external_uid else False}") + logger.info( + f" Existing link found: external_uid={existing_link.external_uid}, external_href={existing_link.external_href}" + ) + logger.info( + f" Link external_uid starts with 'timetracker-': {existing_link.external_uid.startswith('timetracker-') if existing_link.external_uid else False}" + ) else: logger.info(f" No existing link found - will create new event") - + # Skip entries that were imported FROM CalDAV (to avoid circular sync) # If there's a link but the external_uid doesn't start with "timetracker-", # it means this entry was imported from CalDAV, not created by us # Also handle case where external_uid is None or empty - treat as new sync if existing_link and existing_link.external_uid: if not existing_link.external_uid.startswith("timetracker-"): - logger.info(f"Skipping time entry {time_entry.id} - it was imported from CalDAV (external_uid: {existing_link.external_uid}), avoiding circular sync") + logger.info( + f"Skipping time entry {time_entry.id} - it was imported from CalDAV (external_uid: {existing_link.external_uid}), avoiding circular sync" + ) skipped_count += 1 continue else: logger.info(f" Entry {time_entry.id} has timetracker- UID, will update existing event") elif existing_link and not existing_link.external_uid: # Link exists but external_uid is None/empty - treat as new sync, update the link - logger.info(f"Time entry {time_entry.id} has link with empty external_uid - will create new event and update link") + logger.info( + f"Time entry {time_entry.id} has link with empty external_uid - will create new event and update link" + ) else: logger.info(f" Entry {time_entry.id} has no link - will create new event") - + project = Project.query.get(time_entry.project_id) if time_entry.project_id else None task = Task.query.get(time_entry.task_id) if time_entry.task_id else None - + title_parts = [] if project: title_parts.append(project.name) @@ -1095,40 +1191,50 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, if not title_parts: title_parts.append("Time Entry") title = " - ".join(title_parts) - + description_parts = [] if time_entry.notes: description_parts.append(time_entry.notes) if time_entry.tags: description_parts.append(f"Tags: {time_entry.tags}") - description = "\n\n".join(description_parts) if description_parts else "TimeTracker: Created from time entry" - + description = ( + "\n\n".join(description_parts) if description_parts else "TimeTracker: Created from time entry" + ) + start_utc = local_to_utc(time_entry.start_time) end_utc = local_to_utc(time_entry.end_time) if time_entry.end_time else start_utc + timedelta(hours=1) - + logger.info(f"Syncing time entry {time_entry.id}: {title} from {start_utc} to {end_utc}") - + ical_content = self._generate_icalendar_event( uid=event_uid, title=title, description=description, start=start_utc, end=end_utc, - created=time_entry.created_at.replace(tzinfo=timezone.utc) if time_entry.created_at else datetime.now(timezone.utc), - updated=time_entry.updated_at.replace(tzinfo=timezone.utc) if time_entry.updated_at else datetime.now(timezone.utc), + created=( + time_entry.created_at.replace(tzinfo=timezone.utc) + if time_entry.created_at + else datetime.now(timezone.utc) + ), + updated=( + time_entry.updated_at.replace(tzinfo=timezone.utc) + if time_entry.updated_at + else datetime.now(timezone.utc) + ), ) - + # Always construct our standard event URL (don't use imported event hrefs) filename = f"{event_uid}.ics" - if calendar_url.endswith('/'): + if calendar_url.endswith("/"): event_href = calendar_url + filename else: - event_href = calendar_url + '/' + filename - + event_href = calendar_url + "/" + filename + logger.info(f" Event UID: {event_uid}") logger.info(f" Event href: {event_href}") logger.info(f" Calendar URL: {calendar_url}") - + # Check if we already synced this entry (has link with our UID) if existing_link and existing_link.external_uid == event_uid: # Update existing event we created @@ -1136,7 +1242,9 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, # Use the stored href if it exists and is valid, otherwise use our generated one stored_href = existing_link.external_href if existing_link.external_href else event_href logger.info(f" Using stored href: {stored_href}") - success = client.create_or_update_event(calendar_url, event_uid, ical_content, event_href=stored_href) + success = client.create_or_update_event( + calendar_url, event_uid, ical_content, event_href=stored_href + ) if success: # Update the stored href in case it changed if existing_link.external_href != event_href: @@ -1175,12 +1283,12 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, error_msg = f"Failed to create time entry {time_entry.id} in calendar" errors.append(error_msg) logger.warning(f"{error_msg} - create_or_update_event returned False") - + except Exception as e: error_msg = f"Time entry {time_entry.id}: {str(e)}" errors.append(error_msg) logger.warning(f"Failed to sync time entry {time_entry.id} to CalDAV: {e}") - + # Sync calendar events # Note: IntegrationExternalEventLink requires time_entry_id, so for calendar events we track by external_uid only for calendar_event in calendar_events: @@ -1188,40 +1296,48 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, # Skip calendar events that were imported FROM CalDAV (to avoid circular sync) # We check for the [CalDAV: uid] marker in the description if calendar_event.description and "[CalDAV:" in calendar_event.description: - logger.info(f"Skipping calendar event {calendar_event.id} - it was imported from CalDAV (has [CalDAV: marker in description), avoiding circular sync") + logger.info( + f"Skipping calendar event {calendar_event.id} - it was imported from CalDAV (has [CalDAV: marker in description), avoiding circular sync" + ) skipped_count += 1 continue - + event_uid = f"timetracker-calendarevent-{calendar_event.id}@timetracker.local" - + # For calendar events, check by external_uid only (since IntegrationExternalEventLink # requires time_entry_id which calendar events don't have) existing_link_by_uid = IntegrationExternalEventLink.query.filter_by( - integration_id=self.integration.id, - external_uid=event_uid + integration_id=self.integration.id, external_uid=event_uid ).first() - + # Log event details for debugging - logger.info(f"Processing calendar event {calendar_event.id}: start={calendar_event.start_time}, end={calendar_event.end_time}, title={calendar_event.title}") + logger.info( + f"Processing calendar event {calendar_event.id}: start={calendar_event.start_time}, end={calendar_event.end_time}, title={calendar_event.title}" + ) if existing_link_by_uid: - logger.info(f" Existing link found: external_uid={existing_link_by_uid.external_uid}, external_href={existing_link_by_uid.external_href}, time_entry_id={existing_link_by_uid.time_entry_id}") + logger.info( + f" Existing link found: external_uid={existing_link_by_uid.external_uid}, external_href={existing_link_by_uid.external_href}, time_entry_id={existing_link_by_uid.time_entry_id}" + ) # If link exists but has a time_entry_id, it might be for a different entry - we'll update it else: logger.info(f" No existing link found - will create new event") - + # Skip all-day events for now (CalDAV sync currently only handles timed events) if calendar_event.all_day: - logger.info(f"Skipping calendar event {calendar_event.id} - all-day events not yet supported in CalDAV sync") + logger.info( + f"Skipping calendar event {calendar_event.id} - all-day events not yet supported in CalDAV sync" + ) skipped_count += 1 continue - + title = calendar_event.title description_parts = [] if calendar_event.description: # Remove the [CalDAV: uid] marker if present (it's only for tracking imports) desc = calendar_event.description import re - desc = re.sub(r'\[CalDAV: [^\]]+\]\s*\n?\n?', '', desc).strip() + + desc = re.sub(r"\[CalDAV: [^\]]+\]\s*\n?\n?", "", desc).strip() if desc: description_parts.append(desc) if calendar_event.location: @@ -1229,40 +1345,52 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, if calendar_event.event_type: description_parts.append(f"Type: {calendar_event.event_type}") description = "\n\n".join(description_parts) if description_parts else "TimeTracker: Calendar event" - + # Convert to UTC start_utc = local_to_utc(calendar_event.start_time) end_utc = local_to_utc(calendar_event.end_time) - + logger.info(f"Syncing calendar event {calendar_event.id}: {title} from {start_utc} to {end_utc}") - + ical_content = self._generate_icalendar_event( uid=event_uid, title=title, description=description, start=start_utc, end=end_utc, - created=calendar_event.created_at.replace(tzinfo=timezone.utc) if calendar_event.created_at else datetime.now(timezone.utc), - updated=calendar_event.updated_at.replace(tzinfo=timezone.utc) if calendar_event.updated_at else datetime.now(timezone.utc), + created=( + calendar_event.created_at.replace(tzinfo=timezone.utc) + if calendar_event.created_at + else datetime.now(timezone.utc) + ), + updated=( + calendar_event.updated_at.replace(tzinfo=timezone.utc) + if calendar_event.updated_at + else datetime.now(timezone.utc) + ), ) - + # Construct event URL filename = f"{event_uid}.ics" - if calendar_url.endswith('/'): + if calendar_url.endswith("/"): event_href = calendar_url + filename else: - event_href = calendar_url + '/' + filename - + event_href = calendar_url + "/" + filename + logger.info(f" Event UID: {event_uid}") logger.info(f" Event href: {event_href}") - + # Check if we already synced this event if existing_link_by_uid and existing_link_by_uid.external_uid == event_uid: # Update existing event logger.info(f"Updating existing event for calendar event {calendar_event.id} at {event_href}") - stored_href = existing_link_by_uid.external_href if existing_link_by_uid.external_href else event_href + stored_href = ( + existing_link_by_uid.external_href if existing_link_by_uid.external_href else event_href + ) logger.info(f" Using stored href: {stored_href}") - success = client.create_or_update_event(calendar_url, event_uid, ical_content, event_href=stored_href) + success = client.create_or_update_event( + calendar_url, event_uid, ical_content, event_href=stored_href + ) if success: if existing_link_by_uid.external_href != event_href: existing_link_by_uid.external_href = event_href @@ -1293,62 +1421,75 @@ def _sync_time_tracker_to_calendar(self, cfg: Dict[str, Any], calendar_url: str, # Can't create IntegrationExternalEventLink without time_entry_id # So we'll just track by UID in future queries # This means we'll try to sync every time, but the UID check prevents duplicates - logger.info(f" Event created but no link record (calendar events don't have time_entry_id)") + logger.info( + f" Event created but no link record (calendar events don't have time_entry_id)" + ) synced += 1 logger.info(f"Successfully created event for calendar event {calendar_event.id}") else: error_msg = f"Failed to create calendar event {calendar_event.id} in calendar" errors.append(error_msg) logger.warning(f"{error_msg} - create_or_update_event returned False") - + except Exception as e: error_msg = f"Calendar event {calendar_event.id}: {str(e)}" errors.append(error_msg) logger.warning(f"Failed to sync calendar event {calendar_event.id} to CalDAV: {e}") - + self.integration.last_sync_at = datetime.utcnow() self.integration.last_sync_status = "success" if not errors else "partial" self.integration.last_error = "; ".join(errors[:3]) if errors else None - + db.session.commit() - + total_processed = len(time_entries) + len(calendar_events) message = f"Synced {synced} new events, updated {updated} events to CalDAV calendar." logger.info(f"CalDAV TimeTrackerโ†’Calendar sync completed: {message}") - logger.info(f" Summary: {total_processed} items processed ({len(time_entries)} time entries, {len(calendar_events)} calendar events), {synced} created, {updated} updated, {skipped_count} skipped, {len(errors)} errors") - + logger.info( + f" Summary: {total_processed} items processed ({len(time_entries)} time entries, {len(calendar_events)} calendar events), {synced} created, {updated} updated, {skipped_count} skipped, {len(errors)} errors" + ) + return { "success": True, "synced_items": synced + updated, "errors": errors, "message": message, } - - def _generate_icalendar_event(self, uid: str, title: str, description: str, start: datetime, end: datetime, created: datetime, updated: datetime) -> str: + + def _generate_icalendar_event( + self, + uid: str, + title: str, + description: str, + start: datetime, + end: datetime, + created: datetime, + updated: datetime, + ) -> str: """Generate iCalendar content for an event.""" from icalendar import Event - + event = Event() - event.add('uid', uid) - event.add('summary', title) - event.add('description', description) - event.add('dtstart', start) - event.add('dtend', end) - event.add('dtstamp', datetime.now(timezone.utc)) - event.add('created', created) - event.add('last-modified', updated) - event.add('status', 'CONFIRMED') - event.add('transp', 'OPAQUE') - + event.add("uid", uid) + event.add("summary", title) + event.add("description", description) + event.add("dtstart", start) + event.add("dtend", end) + event.add("dtstamp", datetime.now(timezone.utc)) + event.add("created", created) + event.add("last-modified", updated) + event.add("status", "CONFIRMED") + event.add("transp", "OPAQUE") + cal = Calendar() - cal.add('prodid', '-//TimeTracker//CalDAV Integration//EN') - cal.add('version', '2.0') - cal.add('calscale', 'GREGORIAN') - cal.add('method', 'PUBLISH') + cal.add("prodid", "-//TimeTracker//CalDAV Integration//EN") + cal.add("version", "2.0") + cal.add("calscale", "GREGORIAN") + cal.add("method", "PUBLISH") cal.add_component(event) - - return cal.to_ical().decode('utf-8') - + + return cal.to_ical().decode("utf-8") + def get_config_schema(self) -> Dict[str, Any]: """Get configuration schema.""" return { @@ -1466,7 +1607,15 @@ def get_config_schema(self) -> Dict[str, Any]: { "title": "Sync Settings", "description": "Configure what and how to sync", - "fields": ["sync_direction", "sync_items", "default_project_id", "lookback_days", "lookahead_days", "auto_sync", "sync_interval"], + "fields": [ + "sync_direction", + "sync_items", + "default_project_id", + "lookback_days", + "lookahead_days", + "auto_sync", + "sync_interval", + ], }, ], "sync_settings": { @@ -1477,5 +1626,3 @@ def get_config_schema(self) -> Dict[str, Any]: "sync_items": ["events"], }, } - - diff --git a/app/integrations/github.py b/app/integrations/github.py index d14a097f..db574264 100644 --- a/app/integrations/github.py +++ b/app/integrations/github.py @@ -2,11 +2,13 @@ GitHub integration connector. """ -from typing import Dict, Any, Optional +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, Optional + import requests -import os + +from app.integrations.base import BaseConnector class GitHubConnector(BaseConnector): @@ -134,10 +136,11 @@ def test_connection(self) -> Dict[str, Any]: def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync issues from GitHub repositories and create tasks.""" - from app.models import Task, Project - from app import db - from datetime import datetime, timedelta import logging + from datetime import datetime, timedelta + + from app import db + from app.models import Project, Task logger = logging.getLogger(__name__) @@ -153,15 +156,20 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: repos_response = requests.get( "https://api.github.com/user/repos", headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, - timeout=30 + timeout=30, ) if repos_response.status_code == 200: repos = repos_response.json() repos_list = [f"{r['owner']['login']}/{r['name']}" for r in repos[:10]] # Limit to 10 repos elif repos_response.status_code == 401: - return {"success": False, "message": "GitHub authentication failed. Please reconnect the integration."} + return { + "success": False, + "message": "GitHub authentication failed. Please reconnect the integration.", + } else: - error_msg = f"Could not fetch repositories: {repos_response.status_code} - {repos_response.text[:200]}" + error_msg = ( + f"Could not fetch repositories: {repos_response.status_code} - {repos_response.text[:200]}" + ) logger.error(error_msg) return {"success": False, "message": error_msg} except requests.exceptions.Timeout: @@ -186,7 +194,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if "/" not in repo: errors.append(f"Invalid repository format: {repo} (expected owner/repo)") continue - + owner, repo_name = repo.split("/", 1) # Find or create project @@ -213,7 +221,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: f"https://api.github.com/repos/{repo}/issues", headers={"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}, params={"state": "open", "per_page": 100}, - timeout=30 + timeout=30, ) if issues_response.status_code == 404: @@ -224,7 +232,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: continue elif issues_response.status_code != 200: error_text = issues_response.text[:200] if issues_response.text else "" - errors.append(f"Error fetching issues for {repo}: {issues_response.status_code} - {error_text}") + errors.append( + f"Error fetching issues for {repo}: {issues_response.status_code} - {error_text}" + ) continue issues = issues_response.json() @@ -265,7 +275,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: db.session.flush() except Exception as e: errors.append(f"Error creating task for issue #{issue_number} in {repo}: {str(e)}") - logger.error(f"Error creating task for issue #{issue_number} in {repo}: {e}", exc_info=True) + logger.error( + f"Error creating task for issue #{issue_number} in {repo}: {e}", exc_info=True + ) continue # Store GitHub issue info in task metadata @@ -282,7 +294,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: synced_count += 1 except Exception as e: errors.append(f"Error syncing issue #{issue.get('number', 'unknown')} in {repo}: {str(e)}") - logger.error(f"Error syncing issue #{issue.get('number', 'unknown')} in {repo}: {e}", exc_info=True) + logger.error( + f"Error syncing issue #{issue.get('number', 'unknown')} in {repo}: {e}", exc_info=True + ) except ValueError as e: errors.append(f"Invalid repository format: {repo} - {str(e)}") except Exception as e: @@ -305,7 +319,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: "synced_items": synced_count, "errors": errors, } - + return { "success": True, "message": f"Sync completed. Synced {synced_count} issues.", @@ -320,73 +334,62 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: pass return {"success": False, "message": f"Sync failed: {str(e)}", "errors": errors} - def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None) -> Dict[str, Any]: + def handle_webhook( + self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None + ) -> Dict[str, Any]: """Handle incoming webhook from GitHub.""" - import hmac import hashlib + import hmac import logging - + logger = logging.getLogger(__name__) - + try: # Verify webhook signature if secret is configured signature = headers.get("X-Hub-Signature-256", "") if signature: # Get webhook secret from integration config webhook_secret = self.integration.config.get("webhook_secret") if self.integration else None - + if webhook_secret: # GitHub sends signature as "sha256=" if not signature.startswith("sha256="): logger.warning("GitHub webhook signature format invalid (expected sha256= prefix)") - return { - "success": False, - "message": "Invalid webhook signature format" - } - + return {"success": False, "message": "Invalid webhook signature format"} + signature_hash = signature[7:] # Remove "sha256=" prefix - + # GitHub signs the raw request body bytes, not the parsed JSON # This is critical for signature verification to work correctly if raw_body is None: # Fallback: try to reconstruct from payload (not ideal but better than nothing) import json - raw_body = json.dumps(payload, sort_keys=True, separators=(',', ':')).encode('utf-8') - logger.warning("GitHub webhook: Using reconstructed payload for signature verification (raw body not available)") - + + raw_body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + logger.warning( + "GitHub webhook: Using reconstructed payload for signature verification (raw body not available)" + ) + # Compute expected signature using raw body bytes - expected_signature = hmac.new( - webhook_secret.encode('utf-8'), - raw_body, - hashlib.sha256 - ).hexdigest() - + expected_signature = hmac.new(webhook_secret.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() + # Use constant-time comparison to prevent timing attacks if not hmac.compare_digest(signature_hash, expected_signature): logger.warning("GitHub webhook signature verification failed") - return { - "success": False, - "message": "Webhook signature verification failed" - } - + return {"success": False, "message": "Webhook signature verification failed"} + logger.debug("GitHub webhook signature verified successfully") else: # Signature provided but no secret configured - reject for security logger.warning("GitHub webhook signature provided but no secret configured - rejecting webhook") - return { - "success": False, - "message": "Webhook secret not configured" - } + return {"success": False, "message": "Webhook secret not configured"} else: # No signature provided - check if secret is configured webhook_secret = self.integration.config.get("webhook_secret") if self.integration else None if webhook_secret: # Secret configured but no signature - reject for security logger.warning("GitHub webhook secret configured but no signature provided - rejecting webhook") - return { - "success": False, - "message": "Webhook signature required but not provided" - } + return {"success": False, "message": "Webhook signature required but not provided"} # Process webhook event action = payload.get("action") diff --git a/app/integrations/gitlab.py b/app/integrations/gitlab.py index ff0b1768..2753040d 100644 --- a/app/integrations/gitlab.py +++ b/app/integrations/gitlab.py @@ -3,11 +3,13 @@ Sync issues and track time from GitLab. """ -from typing import Dict, Any, Optional, List +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, List, Optional + import requests -import os + +from app.integrations.base import BaseConnector class GitLabConnector(BaseConnector): @@ -107,6 +109,7 @@ def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, An except Exception as e: # Log error but don't fail - user info is optional import logging + logger = logging.getLogger(__name__) logger.debug(f"Could not fetch GitLab user info: {e}") diff --git a/app/integrations/google_calendar.py b/app/integrations/google_calendar.py index bae1054c..c9ef1cc3 100644 --- a/app/integrations/google_calendar.py +++ b/app/integrations/google_calendar.py @@ -3,17 +3,19 @@ Provides two-way sync between TimeTracker and Google Calendar. """ -from typing import Dict, Any, Optional, List +import os from datetime import datetime, timedelta, timezone -from app.integrations.base import BaseConnector +from typing import Any, Dict, List, Optional + import requests -import os +from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from google_auth_oauthlib.flow import Flow -from google.auth.transport.requests import Request from googleapiclient.discovery import build from googleapiclient.errors import HttpError +from app.integrations.base import BaseConnector + class GoogleCalendarConnector(BaseConnector): """Google Calendar integration connector.""" @@ -60,9 +62,9 @@ def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: authorization_url, _ = flow.authorization_url( state=state, # Explicitly pass state parameter - access_type="offline", - include_granted_scopes="true", - prompt="consent" # Force consent to get refresh token + access_type="offline", + include_granted_scopes="true", + prompt="consent", # Force consent to get refresh token ) return authorization_url @@ -113,6 +115,7 @@ def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, An except Exception as e: # Log error but don't fail - user info is optional import logging + logger = logging.getLogger(__name__) logger.debug(f"Could not fetch Google user info: {e}") @@ -167,7 +170,7 @@ def test_connection(self) -> Dict[str, Any]: """Test connection to Google Calendar.""" if not self.credentials: return {"success": False, "message": "No credentials available. Please connect the integration first."} - + try: service = self._get_calendar_service() calendar_list = service.calendarList().list().execute() @@ -195,10 +198,10 @@ def _get_calendar_service(self): """Get Google Calendar API service.""" if not self.credentials: raise ValueError("No credentials available. Please connect the integration first.") - + if not self.credentials.access_token: raise ValueError("No access token available. Please reconnect the integration.") - + from app.models import Settings from app.utils.db import safe_commit @@ -230,13 +233,14 @@ def _get_calendar_service(self): def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync time entries and calendar events with Google Calendar (bidirectional).""" - from app.models import TimeEntry, CalendarEvent - from app.models.integration_external_event_link import IntegrationExternalEventLink - from app import db + import logging from datetime import datetime, timedelta + + from app import db + from app.models import CalendarEvent, TimeEntry + from app.models.integration_external_event_link import IntegrationExternalEventLink from app.utils.timezone import now_in_app_timezone - import logging - + logger = logging.getLogger(__name__) try: @@ -246,8 +250,10 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Default to bidirectional for better user experience (allows both directions) sync_direction = self.integration.config.get("sync_direction", "bidirectional") calendar_id = self.integration.config.get("calendar_id", "primary") - - logger.info(f"Sync configuration: sync_direction='{sync_direction}', calendar_id='{calendar_id}', sync_type='{sync_type}'") + + logger.info( + f"Sync configuration: sync_direction='{sync_direction}', calendar_id='{calendar_id}', sync_type='{sync_type}'" + ) # Initialize counters for both sync directions time_tracker_to_calendar_count = 0 @@ -268,7 +274,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: else: start_date = datetime.utcnow() - timedelta(days=90) - logger.info(f"TimeTrackerโ†’Calendar sync starting: sync_direction='{sync_direction}', sync_type='{sync_type}'") + logger.info( + f"TimeTrackerโ†’Calendar sync starting: sync_direction='{sync_direction}', sync_type='{sync_type}'" + ) logger.info(f" Calendar ID: {calendar_id}") logger.info(f" Time range: from {start_date}") @@ -286,21 +294,24 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: CalendarEvent.all_day == False, # Skip all-day events ).all() - logger.info(f"Found {len(time_entries)} time entries and {len(calendar_events)} calendar events to sync to Google Calendar") + logger.info( + f"Found {len(time_entries)} time entries and {len(calendar_events)} calendar events to sync to Google Calendar" + ) # Sync time entries for entry in time_entries: try: # Check if already synced using IntegrationExternalEventLink existing_link = IntegrationExternalEventLink.query.filter_by( - integration_id=self.integration.id, - time_entry_id=entry.id + integration_id=self.integration.id, time_entry_id=entry.id ).first() existing_event_id = existing_link.external_uid if existing_link else None if existing_event_id: # Update existing event - logger.debug(f"Updating existing calendar event {existing_event_id} for time entry {entry.id}") + logger.debug( + f"Updating existing calendar event {existing_event_id} for time entry {entry.id}" + ) self._update_calendar_event(service, calendar_id, existing_event_id, entry) else: # Create new event @@ -331,31 +342,38 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: try: # Skip calendar events that were imported FROM Google Calendar (to avoid circular sync) if calendar_event.description and "[Google Calendar:" in calendar_event.description: - logger.debug(f"Skipping calendar event {calendar_event.id} - it was imported from Google Calendar, avoiding circular sync") + logger.debug( + f"Skipping calendar event {calendar_event.id} - it was imported from Google Calendar, avoiding circular sync" + ) continue # Check if already synced by querying Google Calendar for events with our marker # Note: IntegrationExternalEventLink requires time_entry_id, so we can't use it for CalendarEvent marker = f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]" - + # Query Google Calendar to find existing events with this marker # Use a time range around the calendar event to find it from app.utils.timezone import local_to_utc + event_start_utc = local_to_utc(calendar_event.start_time) time_min = (event_start_utc - timedelta(days=1)).isoformat() + "Z" time_max = (event_start_utc + timedelta(days=1)).isoformat() + "Z" - + try: - existing_events_result = service.events().list( - calendarId=calendar_id, - timeMin=time_min, - timeMax=time_max, - maxResults=50, - singleEvents=True, - orderBy="startTime", - ).execute() + existing_events_result = ( + service.events() + .list( + calendarId=calendar_id, + timeMin=time_min, + timeMax=time_max, + maxResults=50, + singleEvents=True, + orderBy="startTime", + ) + .execute() + ) existing_events = existing_events_result.get("items", []) - + # Find event with our marker existing_event_id = None for gc_event in existing_events: @@ -369,8 +387,12 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if existing_event_id: # Update existing event - logger.debug(f"Updating existing Google Calendar event {existing_event_id} for CalendarEvent {calendar_event.id}") - self._update_calendar_event_from_event(service, calendar_id, existing_event_id, calendar_event) + logger.debug( + f"Updating existing Google Calendar event {existing_event_id} for CalendarEvent {calendar_event.id}" + ) + self._update_calendar_event_from_event( + service, calendar_id, existing_event_id, calendar_event + ) else: # Create new event logger.debug(f"Creating new Google Calendar event for CalendarEvent {calendar_event.id}") @@ -383,8 +405,10 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: error_msg = f"Error syncing calendar event {calendar_event.id}: {str(e)}" errors.append(error_msg) logger.warning(f"{error_msg}", exc_info=True) - - logger.info(f"TimeTrackerโ†’Calendar sync completed: synced {time_tracker_to_calendar_count} items ({len(time_entries)} time entries + {len(calendar_events)} calendar events)") + + logger.info( + f"TimeTrackerโ†’Calendar sync completed: synced {time_tracker_to_calendar_count} items ({len(time_entries)} time entries + {len(calendar_events)} calendar events)" + ) # Sync Google Calendar โ†’ TimeTracker if sync_direction in ["calendar_to_time_tracker", "bidirectional"]: @@ -393,7 +417,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if sync_type == "incremental" and self.integration.last_sync_at: time_min = self.integration.last_sync_at - logger.info(f"Google Calendar sync starting: sync_direction='{sync_direction}', sync_type='{sync_type}'") + logger.info( + f"Google Calendar sync starting: sync_direction='{sync_direction}', sync_type='{sync_type}'" + ) logger.info(f" Calendar ID: {calendar_id}") logger.info(f" Time range: from {time_min}") @@ -411,20 +437,26 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: events = events_result.get("items", []) logger.info(f"Fetched {len(events)} events from Google Calendar (calendar_id: {calendar_id})") - + if len(events) == 0: logger.info("No events found in Google Calendar for the specified time range") - + # Reset counters for calendar-to-tracker sync (already initialized above) imported = 0 skipped = 0 - skipped_reasons = {"time_tracker_created": 0, "already_imported": 0, "invalid_time": 0, "all_day": 0, "other": 0} + skipped_reasons = { + "time_tracker_created": 0, + "already_imported": 0, + "invalid_time": 0, + "all_day": 0, + "other": 0, + } for event in events: try: event_id = event.get("id") event_summary = event.get("summary", "No title") - + # Skip events we created (check description for marker) description = event.get("description") or "" if description.startswith("TimeTracker:"): @@ -436,18 +468,17 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Check if we already have this event (using CalendarEvent marker, like CalDAV) from app.models import CalendarEvent from app.models.integration_external_event_link import IntegrationExternalEventLink - + existing_calendar_event = CalendarEvent.query.filter( CalendarEvent.user_id == self.integration.user_id, - CalendarEvent.description.like(f"%[Google Calendar: {event_id}]%") + CalendarEvent.description.like(f"%[Google Calendar: {event_id}]%"), ).first() - + # Also check link table in case it was previously imported as TimeEntry (for backward compatibility) existing_link = IntegrationExternalEventLink.query.filter_by( - integration_id=self.integration.id, - external_uid=event_id + integration_id=self.integration.id, external_uid=event_id ).first() - + if existing_calendar_event or existing_link: logger.debug(f"Event {event_id} ({event_summary}) already imported, skipping") skipped += 1 @@ -457,12 +488,14 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Get start and end times - handle both dateTime (timed events) and date (all-day events) start_data = event.get("start", {}) end_data = event.get("end", {}) - + start_str = start_data.get("dateTime") end_str = end_data.get("dateTime") # Skip all-day events (they only have "date", not "dateTime") if not start_str or not end_str: - logger.debug(f"Skipping all-day event {event_id} ({event_summary}) - only timed events are imported") + logger.debug( + f"Skipping all-day event {event_id} ({event_summary}) - only timed events are imported" + ) skipped += 1 skipped_reasons["all_day"] += 1 continue @@ -472,11 +505,13 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Handle Z suffix and convert to +00:00 for fromisoformat start_str_normalized = start_str.replace("Z", "+00:00") end_str_normalized = end_str.replace("Z", "+00:00") - + start_time_utc = datetime.fromisoformat(start_str_normalized) end_time_utc = datetime.fromisoformat(end_str_normalized) except (ValueError, AttributeError) as parse_error: - logger.warning(f"Event {event_id} has invalid datetime format: start={start_str}, end={end_str}, error={parse_error}") + logger.warning( + f"Event {event_id} has invalid datetime format: start={start_str}, end={end_str}, error={parse_error}" + ) skipped += 1 skipped_reasons["invalid_time"] += 1 continue @@ -486,20 +521,23 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: start_time_utc = start_time_utc.replace(tzinfo=timezone.utc) else: start_time_utc = start_time_utc.astimezone(timezone.utc) - + if end_time_utc.tzinfo is None: end_time_utc = end_time_utc.replace(tzinfo=timezone.utc) else: end_time_utc = end_time_utc.astimezone(timezone.utc) if end_time_utc <= start_time_utc: - logger.warning(f"Event {event_id} has invalid time range: start={start_time_utc}, end={end_time_utc}") + logger.warning( + f"Event {event_id} has invalid time range: start={start_time_utc}, end={end_time_utc}" + ) skipped += 1 skipped_reasons["invalid_time"] += 1 continue # Convert UTC to local naive datetime (CalendarEvent stores local naive datetimes) from app.utils.timezone import utc_to_local + start_time_local = utc_to_local(start_time_utc).replace(tzinfo=None) end_time_local = utc_to_local(end_time_utc).replace(tzinfo=None) @@ -544,21 +582,23 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Note: We don't create IntegrationExternalEventLink for CalendarEvent since it requires time_entry_id # We track imports by checking for the [Google Calendar: event_id] marker in the description field imported += 1 - logger.info(f"Imported event {event_id} ({event_summary}) as CalendarEvent {calendar_event.id} (start: {start_time_local}, end: {end_time_local})") + logger.info( + f"Imported event {event_id} ({event_summary}) as CalendarEvent {calendar_event.id} (start: {start_time_local}, end: {end_time_local})" + ) except Exception as e: error_msg = f"Error syncing calendar event {event.get('id', 'unknown')}: {str(e)}" errors.append(error_msg) logger.warning(f"{error_msg}", exc_info=True) skipped += 1 skipped_reasons["other"] += 1 - + # Log detailed summary logger.info( f"Calendarโ†’TimeTracker sync completed: " f"total_events={len(events)}, imported={imported}, skipped={skipped} " f"(reasons: {dict(skipped_reasons)})" ) - + if imported == 0 and len(events) > 0: logger.warning( f"No events were imported despite {len(events)} events found. " @@ -574,7 +614,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Commit all changes in a single transaction (time entries, links, integration status) try: db.session.commit() - logger.info(f"Committed sync results: TimeTrackerโ†’Calendar={time_tracker_to_calendar_count}, Calendarโ†’TimeTracker imported={imported}") + logger.info( + f"Committed sync results: TimeTrackerโ†’Calendar={time_tracker_to_calendar_count}, Calendarโ†’TimeTracker imported={imported}" + ) except Exception as commit_error: db.session.rollback() logger.error(f"Failed to commit sync results: {commit_error}", exc_info=True) @@ -596,7 +638,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if skipped > 0: skipped_summary = ", ".join([f"{k}={v}" for k, v in skipped_reasons.items() if v > 0]) message_parts.append(f"({skipped} skipped: {skipped_summary})") - + total_synced = time_tracker_to_calendar_count + imported message = " | ".join(message_parts) if message_parts else f"Synced {total_synced} items" @@ -645,6 +687,7 @@ def _create_calendar_event(self, service, calendar_id: str, time_entry) -> str: # Convert local naive datetimes to UTC for Google Calendar API from app.utils.timezone import local_to_utc + start_time_utc = local_to_utc(time_entry.start_time) end_time_utc = local_to_utc(time_entry.end_time) @@ -699,6 +742,7 @@ def _update_calendar_event(self, service, calendar_id: str, event_id: str, time_ # Convert local naive datetimes to UTC for Google Calendar API from app.utils.timezone import local_to_utc + start_time_utc = local_to_utc(time_entry.start_time) end_time_utc = local_to_utc(time_entry.end_time) @@ -720,31 +764,36 @@ def _update_calendar_event(self, service, calendar_id: str, event_id: str, time_ def _create_calendar_event_from_event(self, service, calendar_id: str, calendar_event) -> str: """Create a Google Calendar event from a CalendarEvent object.""" import re - + # Use calendar event title title = calendar_event.title or "Calendar Event" - + # Build description - remove import markers if present description_parts = [] description_parts.append(f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]") - + if calendar_event.description: # Remove the [Google Calendar: event_id] marker if present (it's only for tracking imports) desc = calendar_event.description - desc = re.sub(r'\[Google Calendar: [^\]]+\]\s*\n?\n?', '', desc).strip() + desc = re.sub(r"\[Google Calendar: [^\]]+\]\s*\n?\n?", "", desc).strip() if desc: description_parts.append(desc) - + if calendar_event.location: description_parts.append(f"Location: {calendar_event.location}") - + if calendar_event.event_type: description_parts.append(f"Type: {calendar_event.event_type}") - - description = "\n\n".join(description_parts) if description_parts else f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]" + + description = ( + "\n\n".join(description_parts) + if description_parts + else f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]" + ) # Convert local naive datetimes to UTC for Google Calendar API from app.utils.timezone import local_to_utc + start_time_utc = local_to_utc(calendar_event.start_time) end_time_utc = local_to_utc(calendar_event.end_time) @@ -768,34 +817,39 @@ def _create_calendar_event_from_event(self, service, calendar_id: str, calendar_ def _update_calendar_event_from_event(self, service, calendar_id: str, event_id: str, calendar_event): """Update an existing Google Calendar event from a CalendarEvent object.""" import re - + # Use calendar event title title = calendar_event.title or "Calendar Event" - + # Build description - remove import markers if present description_parts = [] description_parts.append(f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]") - + if calendar_event.description: # Remove the [Google Calendar: event_id] marker if present (it's only for tracking imports) desc = calendar_event.description - desc = re.sub(r'\[Google Calendar: [^\]]+\]\s*\n?\n?', '', desc).strip() + desc = re.sub(r"\[Google Calendar: [^\]]+\]\s*\n?\n?", "", desc).strip() if desc: description_parts.append(desc) - + if calendar_event.location: description_parts.append(f"Location: {calendar_event.location}") - + if calendar_event.event_type: description_parts.append(f"Type: {calendar_event.event_type}") - - description = "\n\n".join(description_parts) if description_parts else f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]" + + description = ( + "\n\n".join(description_parts) + if description_parts + else f"TimeTracker: Created from calendar event [CalendarEvent: {calendar_event.id}]" + ) # Get existing event event = service.events().get(calendarId=calendar_id, eventId=event_id).execute() # Convert local naive datetimes to UTC for Google Calendar API from app.utils.timezone import local_to_utc + start_time_utc = local_to_utc(calendar_event.start_time) end_time_utc = local_to_utc(calendar_event.end_time) @@ -907,7 +961,14 @@ def get_config_schema(self) -> Dict[str, Any]: { "title": "Sync Settings", "description": "Configure what and how to sync", - "fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval", "sync_past_days", "sync_future_days"], + "fields": [ + "sync_direction", + "sync_items", + "auto_sync", + "sync_interval", + "sync_past_days", + "sync_future_days", + ], }, { "title": "Display Settings", diff --git a/app/integrations/jira.py b/app/integrations/jira.py index ab1da3e5..613507ff 100644 --- a/app/integrations/jira.py +++ b/app/integrations/jira.py @@ -2,11 +2,13 @@ Jira integration connector. """ -from typing import Dict, Any, Optional +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, Optional + import requests -import os + +from app.integrations.base import BaseConnector class JiraConnector(BaseConnector): @@ -147,10 +149,11 @@ def test_connection(self) -> Dict[str, Any]: def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync issues from Jira and create tasks.""" - from app.models import Task, Project - from app import db from datetime import datetime, timedelta + from app import db + from app.models import Project, Task + token = self.get_access_token() if not token: return {"success": False, "message": "No access token available"} @@ -257,7 +260,7 @@ def _map_jira_status(self, jira_status: str) -> str: status_mapping = self.get_status_mappings() if status_mapping and jira_status in status_mapping: return status_mapping[jira_status] - + # Default mapping status_map = { "To Do": "todo", @@ -267,12 +270,14 @@ def _map_jira_status(self, jira_status: str) -> str: } return status_map.get(jira_status, "todo") - def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None) -> Dict[str, Any]: + def handle_webhook( + self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None + ) -> Dict[str, Any]: """Handle incoming webhook from Jira.""" import logging - + logger = logging.getLogger(__name__) - + try: event_type = payload.get("webhookEvent") issue = payload.get("issue", {}) diff --git a/app/integrations/microsoft_teams.py b/app/integrations/microsoft_teams.py index c61d05ec..a82ed0e3 100644 --- a/app/integrations/microsoft_teams.py +++ b/app/integrations/microsoft_teams.py @@ -3,11 +3,13 @@ Send notifications and sync with Microsoft Teams. """ -from typing import Dict, Any, Optional, List +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, List, Optional + import requests -import os + +from app.integrations.base import BaseConnector class MicrosoftTeamsConnector(BaseConnector): @@ -115,6 +117,7 @@ def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, An except Exception as e: # Log error but don't fail - user info is optional import logging + logger = logging.getLogger(__name__) logger.debug(f"Could not fetch Microsoft Teams user info: {e}") @@ -351,7 +354,13 @@ def get_config_schema(self) -> Dict[str, Any]: { "title": "Notification Settings", "description": "Configure when to send Teams notifications", - "fields": ["notify_on_time_entry_start", "notify_on_time_entry_complete", "notify_on_task_complete", "notify_on_invoice_sent", "notify_on_project_create"], + "fields": [ + "notify_on_time_entry_start", + "notify_on_time_entry_complete", + "notify_on_task_complete", + "notify_on_invoice_sent", + "notify_on_project_create", + ], }, ], "sync_settings": { diff --git a/app/integrations/outlook_calendar.py b/app/integrations/outlook_calendar.py index 93e9aa11..0833280a 100644 --- a/app/integrations/outlook_calendar.py +++ b/app/integrations/outlook_calendar.py @@ -3,11 +3,13 @@ Provides two-way sync between TimeTracker and Outlook Calendar. """ -from typing import Dict, Any, Optional, List +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, List, Optional + import requests -import os + +from app.integrations.base import BaseConnector class OutlookCalendarConnector(BaseConnector): @@ -116,6 +118,7 @@ def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, An except Exception as e: # Log error but don't fail - user info is optional import logging + logger = logging.getLogger(__name__) logger.debug(f"Could not fetch Outlook user info: {e}") @@ -199,10 +202,11 @@ def test_connection(self) -> Dict[str, Any]: def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync time entries with Outlook Calendar.""" - from app.models import TimeEntry - from app import db from datetime import datetime, timedelta + from app import db + from app.models import TimeEntry + try: token = self.get_access_token() if not token: @@ -437,7 +441,14 @@ def get_config_schema(self) -> Dict[str, Any]: { "title": "Sync Settings", "description": "Configure what and how to sync", - "fields": ["sync_direction", "sync_items", "auto_sync", "sync_interval", "sync_past_days", "sync_future_days"], + "fields": [ + "sync_direction", + "sync_items", + "auto_sync", + "sync_interval", + "sync_past_days", + "sync_future_days", + ], }, { "title": "Display Settings", diff --git a/app/integrations/peppol.py b/app/integrations/peppol.py index f9695f3c..f1d2c1fb 100644 --- a/app/integrations/peppol.py +++ b/app/integrations/peppol.py @@ -16,14 +16,13 @@ import hashlib import os +import xml.etree.ElementTree as ET from dataclasses import dataclass from datetime import date from decimal import Decimal from typing import Any, Dict, Optional, Tuple import requests -import xml.etree.ElementTree as ET - PEPPOL_BIS3_CUSTOMIZATION_ID = "urn:cen.eu:en16931:2017#compliant#urn:fdc:peppol.eu:2017:poacc:billing:3.0" PEPPOL_BIS3_PROFILE_ID = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0" @@ -272,7 +271,12 @@ def _add_line(description: str, quantity: Any, unit_price: Any, line_total: Any) desc = getattr(ex, "title", "Expense") if getattr(ex, "vendor", None): desc = f"{desc} ({ex.vendor})" - _add_line(description=desc, quantity=1, unit_price=getattr(ex, "total_amount", 0), line_total=getattr(ex, "total_amount", 0)) + _add_line( + description=desc, + quantity=1, + unit_price=getattr(ex, "total_amount", 0), + line_total=getattr(ex, "total_amount", 0), + ) except Exception: pass @@ -334,7 +338,9 @@ def send_ubl_via_access_point( if not url: raise PeppolAccessPointError("PEPPOL_ACCESS_POINT_URL is not set") - token = (access_point_token if access_point_token is not None else os.getenv("PEPPOL_ACCESS_POINT_TOKEN") or "").strip() + token = ( + access_point_token if access_point_token is not None else os.getenv("PEPPOL_ACCESS_POINT_TOKEN") or "" + ).strip() timeout_s = ( float(access_point_timeout_s) if access_point_timeout_s is not None @@ -363,4 +369,3 @@ def send_ubl_via_access_point( raise PeppolAccessPointError(f"Access point returned HTTP {resp.status_code}: {data}") return {"status_code": resp.status_code, "data": data} - diff --git a/app/integrations/peppol_identifiers.py b/app/integrations/peppol_identifiers.py index f2aca753..dea63515 100644 --- a/app/integrations/peppol_identifiers.py +++ b/app/integrations/peppol_identifiers.py @@ -12,26 +12,170 @@ # Common PEPPOL participant identifier schemes (ISO 6523) # See PEPPOL IC and country-specific scheme lists -KNOWN_SCHEMES = frozenset({ - "0007", "0088", "0060", "0130", "0184", "0190", "0191", "0192", "0193", - "0195", "0196", "0198", "0199", "0200", "0201", "0202", "0204", "0208", - "0209", "0210", "0211", "0212", "0213", "0215", "0216", "0218", "0219", - "0220", "0221", "0222", "0223", "0224", "0225", "0226", "0227", "0228", - "0229", "0230", "0231", "0232", "0233", "0234", "0235", "0236", "0237", - "0238", "0239", "0240", "0241", "0242", "0243", "0244", "0245", "0246", - "0247", "0248", "0249", "0250", "0251", "0252", "0253", "0254", "0255", - "0256", "0257", "0258", "0259", "0260", "0261", "0262", "0263", "0264", - "0265", "0266", "0267", "0268", "0269", "0270", "0271", "0272", "0273", - "0274", "0275", "0276", "0277", "0278", "0279", "0280", "0281", "0282", - "0283", "0284", "0285", "0286", "0287", "0288", "0289", "0290", "0291", - "0292", "0293", "0294", "0295", "0296", "0297", "0298", "0299", "0300", - "9915", "9925", "9933", "9944", "9950", "9952", "9954", "9955", "9956", - "9957", "9958", "9959", "9960", "9961", "9962", "9963", "9964", "9965", - "9966", "9967", "9968", "9969", "9970", "9971", "9972", "9973", "9974", - "9975", "9976", "9977", "9978", "9979", "9980", "9981", "9982", "9983", - "9984", "9985", "9986", "9987", "9988", "9989", "9990", "9991", "9992", - "9993", "9994", "9995", "9996", "9997", "9998", "9999", -}) +KNOWN_SCHEMES = frozenset( + { + "0007", + "0088", + "0060", + "0130", + "0184", + "0190", + "0191", + "0192", + "0193", + "0195", + "0196", + "0198", + "0199", + "0200", + "0201", + "0202", + "0204", + "0208", + "0209", + "0210", + "0211", + "0212", + "0213", + "0215", + "0216", + "0218", + "0219", + "0220", + "0221", + "0222", + "0223", + "0224", + "0225", + "0226", + "0227", + "0228", + "0229", + "0230", + "0231", + "0232", + "0233", + "0234", + "0235", + "0236", + "0237", + "0238", + "0239", + "0240", + "0241", + "0242", + "0243", + "0244", + "0245", + "0246", + "0247", + "0248", + "0249", + "0250", + "0251", + "0252", + "0253", + "0254", + "0255", + "0256", + "0257", + "0258", + "0259", + "0260", + "0261", + "0262", + "0263", + "0264", + "0265", + "0266", + "0267", + "0268", + "0269", + "0270", + "0271", + "0272", + "0273", + "0274", + "0275", + "0276", + "0277", + "0278", + "0279", + "0280", + "0281", + "0282", + "0283", + "0284", + "0285", + "0286", + "0287", + "0288", + "0289", + "0290", + "0291", + "0292", + "0293", + "0294", + "0295", + "0296", + "0297", + "0298", + "0299", + "0300", + "9915", + "9925", + "9933", + "9944", + "9950", + "9952", + "9954", + "9955", + "9956", + "9957", + "9958", + "9959", + "9960", + "9961", + "9962", + "9963", + "9964", + "9965", + "9966", + "9967", + "9968", + "9969", + "9970", + "9971", + "9972", + "9973", + "9974", + "9975", + "9976", + "9977", + "9978", + "9979", + "9980", + "9981", + "9982", + "9983", + "9984", + "9985", + "9986", + "9987", + "9988", + "9989", + "9990", + "9991", + "9992", + "9993", + "9994", + "9995", + "9996", + "9997", + "9998", + "9999", + } +) # Endpoint ID: alphanumeric, some schemes allow colon/dash (e.g. 0088:1234567890123) _ENDPOINT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_\-.:]+$") diff --git a/app/integrations/peppol_smp.py b/app/integrations/peppol_smp.py index bb7044e4..15e2ae20 100644 --- a/app/integrations/peppol_smp.py +++ b/app/integrations/peppol_smp.py @@ -70,6 +70,7 @@ def get_smp_url(participant_id: str, scheme_id: str, sml_base_url: Optional[str] actor_urn = f"iso6523-actorid-up::{scheme_id}::{participant_id}" # URL-encode the URN for path import urllib.parse + path = "/" + urllib.parse.quote(actor_urn, safe="") url = base + path @@ -120,6 +121,7 @@ def get_recipient_endpoint_url( """ # SMP 2.0: GET {smp_url}/services/{doc_type}/processes/{process_id} import urllib.parse + doc_encoded = urllib.parse.quote(document_type_id, safe="") proc_encoded = urllib.parse.quote(process_id, safe="") path = f"/services/{doc_encoded}/processes/{proc_encoded}" diff --git a/app/integrations/peppol_transport.py b/app/integrations/peppol_transport.py index e6e88aba..a83a08a1 100644 --- a/app/integrations/peppol_transport.py +++ b/app/integrations/peppol_transport.py @@ -12,18 +12,10 @@ from abc import ABC, abstractmethod from typing import Any, Dict, Optional -from app.integrations.peppol import ( - PEPPOL_BIS3_PROFILE_ID, - PeppolAccessPointError, - send_ubl_via_access_point, -) -from app.integrations.peppol_as4 import build_as4_message, send_as4_message, PeppolAS4Error -from app.integrations.peppol_identifiers import validate_participant_identifiers, PeppolIdentifierError -from app.integrations.peppol_smp import ( - PeppolSMPError, - get_recipient_endpoint_url, - get_smp_url, -) +from app.integrations.peppol import PEPPOL_BIS3_PROFILE_ID, PeppolAccessPointError, send_ubl_via_access_point +from app.integrations.peppol_as4 import PeppolAS4Error, build_as4_message, send_as4_message +from app.integrations.peppol_identifiers import PeppolIdentifierError, validate_participant_identifiers +from app.integrations.peppol_smp import PeppolSMPError, get_recipient_endpoint_url, get_smp_url class PeppolTransportError(RuntimeError): diff --git a/app/integrations/quickbooks.py b/app/integrations/quickbooks.py index bf85bdbc..be176b6a 100644 --- a/app/integrations/quickbooks.py +++ b/app/integrations/quickbooks.py @@ -3,13 +3,15 @@ Sync invoices, expenses, and payments with QuickBooks Online. """ -from typing import Dict, Any, Optional, List -from datetime import datetime, timedelta -from app.integrations.base import BaseConnector -import requests -import os import base64 import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +import requests + +from app.integrations.base import BaseConnector logger = logging.getLogger(__name__) @@ -183,7 +185,9 @@ def test_connection(self) -> Dict[str, Any]: except Exception as e: return {"success": False, "message": f"Connection test failed: {str(e)}"} - def _api_request(self, method: str, endpoint: str, access_token: str, realm_id: str, json_data: Optional[Dict] = None) -> Optional[Dict]: + def _api_request( + self, method: str, endpoint: str, access_token: str, realm_id: str, json_data: Optional[Dict] = None + ) -> Optional[Dict]: """Make API request to QuickBooks""" base_url = self.get_base_url() url = f"{base_url}{endpoint}" @@ -225,7 +229,7 @@ def _api_request(self, method: str, endpoint: str, access_token: str, realm_id: error_detail = error_data.get("fault", {}).get("error", [{}])[0].get("message", "") except Exception: error_detail = e.response.text[:200] if e.response.text else "" - + error_msg = f"QuickBooks API error ({e.response.status_code}): {error_detail or str(e)}" logger.error(f"QuickBooks API request failed: {error_msg}") raise ValueError(error_msg) @@ -235,8 +239,8 @@ def _api_request(self, method: str, endpoint: str, access_token: str, realm_id: def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync invoices and expenses with QuickBooks""" - from app.models import Invoice, Expense from app import db + from app.models import Expense, Invoice try: realm_id = self.integration.config.get("realm_id") @@ -254,15 +258,20 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if sync_type == "full" or sync_type == "invoices": try: invoices = Invoice.query.filter( - Invoice.status.in_(["sent", "paid"]), Invoice.created_at >= datetime.utcnow() - timedelta(days=90) + Invoice.status.in_(["sent", "paid"]), + Invoice.created_at >= datetime.utcnow() - timedelta(days=90), ).all() for invoice in invoices: try: # Skip if already synced (has QuickBooks ID) - if hasattr(invoice, "metadata") and invoice.metadata and invoice.metadata.get("quickbooks_id"): + if ( + hasattr(invoice, "metadata") + and invoice.metadata + and invoice.metadata.get("quickbooks_id") + ): continue - + qb_invoice = self._create_quickbooks_invoice(invoice, access_token, realm_id) if qb_invoice: # Store QuickBooks ID in invoice metadata @@ -293,14 +302,20 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Sync expenses (create as expenses in QuickBooks) if sync_type == "full" or sync_type == "expenses": try: - expenses = Expense.query.filter(Expense.expense_date >= datetime.utcnow().date() - timedelta(days=90)).all() + expenses = Expense.query.filter( + Expense.expense_date >= datetime.utcnow().date() - timedelta(days=90) + ).all() for expense in expenses: try: # Skip if already synced - if hasattr(expense, "metadata") and expense.metadata and expense.metadata.get("quickbooks_id"): + if ( + hasattr(expense, "metadata") + and expense.metadata + and expense.metadata.get("quickbooks_id") + ): continue - + qb_expense = self._create_quickbooks_expense(expense, access_token, realm_id) if qb_expense: if not hasattr(expense, "metadata") or not expense.metadata: @@ -341,10 +356,15 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: "success": True, "synced_count": synced_count, "errors": errors, - "message": f"Sync completed with {len(errors)} error(s). Synced {synced_count} items." + "message": f"Sync completed with {len(errors)} error(s). Synced {synced_count} items.", } - - return {"success": True, "synced_count": synced_count, "errors": errors, "message": f"Successfully synced {synced_count} items."} + + return { + "success": True, + "synced_count": synced_count, + "errors": errors, + "message": f"Successfully synced {synced_count} items.", + } except requests.exceptions.RequestException as e: error_msg = f"Network error during QuickBooks sync: {str(e)}" @@ -360,7 +380,7 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) # Get customer mapping from integration config or invoice metadata customer_mapping = self.integration.config.get("customer_mappings", {}) if self.integration else {} item_mapping = self.integration.config.get("item_mappings", {}) if self.integration else {} - + # Try to get QuickBooks customer ID from mapping or metadata customer_qb_id = None if invoice.client_id: @@ -369,7 +389,7 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) # Fallback to invoice metadata if not customer_qb_id and hasattr(invoice, "metadata") and invoice.metadata: customer_qb_id = invoice.metadata.get("quickbooks_customer_id") - + # If no mapping found, try to find customer by name in QuickBooks if not customer_qb_id and invoice.client_id: try: @@ -379,18 +399,14 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) # QuickBooks query syntax: SELECT * FROM Customer WHERE DisplayName = 'CustomerName' # URL encode the query parameter from urllib.parse import quote + # Escape single quotes for SQL (replace ' with '') escaped_name = customer_name.replace("'", "''") query = f"SELECT * FROM Customer WHERE DisplayName = '{escaped_name}'" query_url = f"/v3/company/{realm_id}/query?query={quote(query)}" - - customers_response = self._api_request( - "GET", - query_url, - access_token, - realm_id - ) - + + customers_response = self._api_request("GET", query_url, access_token, realm_id) + if customers_response and "QueryResponse" in customers_response: customers = customers_response["QueryResponse"].get("Customer", []) if customers: @@ -400,7 +416,7 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) customer_qb_id = customers[0].get("Id") else: customer_qb_id = customers.get("Id") - + if customer_qb_id: # Auto-save mapping for future use if not self.integration.config: @@ -408,23 +424,24 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) if "customer_mappings" not in self.integration.config: self.integration.config["customer_mappings"] = {} self.integration.config["customer_mappings"][str(invoice.client_id)] = customer_qb_id - logger.info(f"Auto-mapped client {invoice.client_id} to QuickBooks customer {customer_qb_id}") + logger.info( + f"Auto-mapped client {invoice.client_id} to QuickBooks customer {customer_qb_id}" + ) else: - logger.warning(f"Customer '{customer_name}' not found in QuickBooks. Please configure customer mapping.") + logger.warning( + f"Customer '{customer_name}' not found in QuickBooks. Please configure customer mapping." + ) except Exception as e: logger.error(f"Error looking up QuickBooks customer: {e}", exc_info=True) - + # If still no customer ID, we cannot create the invoice if not customer_qb_id: error_msg = f"Customer mapping not found for client {invoice.client_id}. Cannot create QuickBooks invoice." logger.error(error_msg) raise ValueError(error_msg) - + # Build QuickBooks invoice structure - qb_invoice = { - "CustomerRef": {"value": customer_qb_id}, - "Line": [] - } + qb_invoice = {"CustomerRef": {"value": customer_qb_id}, "Line": []} # Add invoice items for item in invoice.items: @@ -433,26 +450,22 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) item_qb_id = item_mapping.get(str(item.id)) if not item_qb_id and isinstance(item_mapping.get(item.description), dict): item_qb_id = item_mapping.get(item.description, {}).get("id") - + item_qb_name = item.description or "Service" - + # If no mapping, try to find item by name in QuickBooks if not item_qb_id: try: # Query QuickBooks for item by Name from urllib.parse import quote + # Escape single quotes for SQL (replace ' with '') escaped_name = item_qb_name.replace("'", "''") query = f"SELECT * FROM Item WHERE Name = '{escaped_name}'" query_url = f"/v3/company/{realm_id}/query?query={quote(query)}" - - items_response = self._api_request( - "GET", - query_url, - access_token, - realm_id - ) - + + items_response = self._api_request("GET", query_url, access_token, realm_id) + if items_response and "QueryResponse" in items_response: items = items_response["QueryResponse"].get("Item", []) if items: @@ -462,7 +475,7 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) item_qb_id = items[0].get("Id") else: item_qb_id = items.get("Id") - + if item_qb_id: # Auto-save mapping for future use if "item_mappings" not in self.integration.config: @@ -471,7 +484,7 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) logger.info(f"Auto-mapped invoice item {item.id} to QuickBooks item {item_qb_id}") except Exception as e: logger.warning(f"Error looking up QuickBooks item '{item_qb_name}': {e}") - + # Build line item line_item = { "Amount": float(item.quantity * item.unit_price), @@ -481,7 +494,7 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) "UnitPrice": float(item.unit_price), }, } - + if item_qb_id: line_item["SalesItemLineDetail"]["ItemRef"] = { "value": item_qb_id, @@ -492,66 +505,66 @@ def _create_quickbooks_invoice(self, invoice, access_token: str, realm_id: str) line_item["SalesItemLineDetail"]["ItemRef"] = { "name": item_qb_name, } - logger.warning(f"Item mapping not found for invoice item {item.id}. Using description as item name.") - + logger.warning( + f"Item mapping not found for invoice item {item.id}. Using description as item name." + ) + qb_invoice["Line"].append(line_item) except Exception as e: logger.error(f"Error processing invoice item {item.id}: {e}", exc_info=True) # Continue with other items instead of failing completely continue - + # Validate invoice has at least one line item if not qb_invoice["Line"]: error_msg = "Invoice has no valid line items" logger.error(error_msg) raise ValueError(error_msg) - + # Add invoice date and due date if invoice.created_at: qb_invoice["TxnDate"] = invoice.created_at.strftime("%Y-%m-%d") if invoice.due_date: qb_invoice["DueDate"] = invoice.due_date.strftime("%Y-%m-%d") - + endpoint = f"/v3/company/{realm_id}/invoice" result = self._api_request("POST", endpoint, access_token, realm_id, json_data=qb_invoice) - + if not result: raise ValueError("Failed to create invoice in QuickBooks - no response from API") - + # Validate response if "Invoice" not in result: raise ValueError(f"Invalid response from QuickBooks API: {result}") - + return result def _create_quickbooks_expense(self, expense, access_token: str, realm_id: str) -> Optional[Dict]: """Create expense in QuickBooks""" # Get account mapping from integration config account_mapping = self.integration.config.get("account_mappings", {}) if self.integration else {} - default_expense_account = self.integration.config.get("default_expense_account_id") if self.integration else None - + default_expense_account = ( + self.integration.config.get("default_expense_account_id") if self.integration else None + ) + # Try to get account ID from expense category mapping or use default account_id = default_expense_account if expense.category_id: account_id = account_mapping.get(str(expense.category_id), default_expense_account) elif hasattr(expense, "metadata") and expense.metadata: account_id = expense.metadata.get("quickbooks_account_id", default_expense_account) - + # If no account ID found, try to find or use default expense account if not account_id: try: # Query for default expense accounts from urllib.parse import quote + query = "SELECT * FROM Account WHERE AccountType = 'Expense' AND Active = true MAXRESULTS 1" query_url = f"/v3/company/{realm_id}/query?query={quote(query)}" - - accounts_response = self._api_request( - "GET", - query_url, - access_token, - realm_id - ) - + + accounts_response = self._api_request("GET", query_url, access_token, realm_id) + if accounts_response and "QueryResponse" in accounts_response: accounts = accounts_response["QueryResponse"].get("Account", []) if accounts: @@ -560,7 +573,7 @@ def _create_quickbooks_expense(self, expense, access_token: str, realm_id: str) account_id = accounts[0].get("Id") else: account_id = accounts.get("Id") - + if account_id: # Auto-save mapping for future use if we found an account if expense.category_id: @@ -569,7 +582,9 @@ def _create_quickbooks_expense(self, expense, access_token: str, realm_id: str) if "account_mappings" not in self.integration.config: self.integration.config["account_mappings"] = {} self.integration.config["account_mappings"][str(expense.category_id)] = account_id - logger.info(f"Auto-mapped expense category {expense.category_id} to QuickBooks account {account_id}") + logger.info( + f"Auto-mapped expense category {expense.category_id} to QuickBooks account {account_id}" + ) else: # No account found - require configuration error_msg = f"No expense account found for expense {expense.id}. Please configure account mapping or set default_expense_account_id in integration config." @@ -587,7 +602,7 @@ def _create_quickbooks_expense(self, expense, access_token: str, realm_id: str) else: error_msg = f"Failed to determine QuickBooks account for expense {expense.id}. Please configure account mapping or default_expense_account_id." raise ValueError(error_msg) - + # Build QuickBooks expense structure qb_expense = { "PaymentType": "Cash", @@ -600,29 +615,29 @@ def _create_quickbooks_expense(self, expense, access_token: str, realm_id: str) } ], } - + # Add vendor if available if expense.vendor: qb_expense["EntityRef"] = {"name": expense.vendor} - + # Add expense date if expense.date: qb_expense["TxnDate"] = expense.date.strftime("%Y-%m-%d") - + # Add memo/description if expense.description: qb_expense["Line"][0]["Description"] = expense.description endpoint = f"/v3/company/{realm_id}/purchase" result = self._api_request("POST", endpoint, access_token, realm_id, json_data=qb_expense) - + if not result: raise ValueError("Failed to create expense in QuickBooks - no response from API") - + # Validate response if "Purchase" not in result: raise ValueError(f"Invalid response from QuickBooks API: {result}") - + return result def get_config_schema(self) -> Dict[str, Any]: @@ -670,8 +685,20 @@ def get_config_schema(self) -> Dict[str, Any]: "default": ["invoices", "expenses"], "description": "Select which items to synchronize", }, - {"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True, "description": "Enable invoice synchronization"}, - {"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True, "description": "Enable expense synchronization"}, + { + "name": "sync_invoices", + "type": "boolean", + "label": "Sync Invoices", + "default": True, + "description": "Enable invoice synchronization", + }, + { + "name": "sync_expenses", + "type": "boolean", + "label": "Sync Expenses", + "default": True, + "description": "Enable expense synchronization", + }, { "name": "auto_sync", "type": "boolean", @@ -707,7 +734,7 @@ def get_config_schema(self) -> Dict[str, Any]: "required": False, "placeholder": '{"1": "qb_customer_id_123", "2": "qb_customer_id_456"}', "description": "JSON mapping of TimeTracker client IDs to QuickBooks customer IDs", - "help": "Map your TimeTracker clients to QuickBooks customers. Format: {\"timetracker_client_id\": \"quickbooks_customer_id\"}", + "help": 'Map your TimeTracker clients to QuickBooks customers. Format: {"timetracker_client_id": "quickbooks_customer_id"}', }, { "name": "item_mappings", @@ -738,7 +765,14 @@ def get_config_schema(self) -> Dict[str, Any]: { "title": "Sync Settings", "description": "Configure what and how to sync", - "fields": ["sync_direction", "sync_items", "sync_invoices", "sync_expenses", "auto_sync", "sync_interval"], + "fields": [ + "sync_direction", + "sync_items", + "sync_invoices", + "sync_expenses", + "auto_sync", + "sync_interval", + ], }, { "title": "Data Mapping", diff --git a/app/integrations/registry.py b/app/integrations/registry.py index e0223826..e9f144b9 100644 --- a/app/integrations/registry.py +++ b/app/integrations/registry.py @@ -3,20 +3,20 @@ Registers all available connectors with the IntegrationService. """ -from app.services.integration_service import IntegrationService -from app.integrations.jira import JiraConnector -from app.integrations.slack import SlackConnector -from app.integrations.github import GitHubConnector -from app.integrations.google_calendar import GoogleCalendarConnector -from app.integrations.outlook_calendar import OutlookCalendarConnector -from app.integrations.caldav_calendar import CalDAVCalendarConnector from app.integrations.activitywatch import ActivityWatchConnector -from app.integrations.microsoft_teams import MicrosoftTeamsConnector from app.integrations.asana import AsanaConnector -from app.integrations.trello import TrelloConnector +from app.integrations.caldav_calendar import CalDAVCalendarConnector +from app.integrations.github import GitHubConnector from app.integrations.gitlab import GitLabConnector +from app.integrations.google_calendar import GoogleCalendarConnector +from app.integrations.jira import JiraConnector +from app.integrations.microsoft_teams import MicrosoftTeamsConnector +from app.integrations.outlook_calendar import OutlookCalendarConnector from app.integrations.quickbooks import QuickBooksConnector +from app.integrations.slack import SlackConnector +from app.integrations.trello import TrelloConnector from app.integrations.xero import XeroConnector +from app.services.integration_service import IntegrationService def register_connectors(): diff --git a/app/integrations/slack.py b/app/integrations/slack.py index d966c5ac..a9e825df 100644 --- a/app/integrations/slack.py +++ b/app/integrations/slack.py @@ -2,11 +2,13 @@ Slack integration connector. """ -from typing import Dict, Any, Optional +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, Optional + import requests -import os + +from app.integrations.base import BaseConnector class SlackConnector(BaseConnector): @@ -217,12 +219,14 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: except Exception as e: return {"success": False, "message": f"Sync failed: {str(e)}"} - def handle_webhook(self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None) -> Dict[str, Any]: + def handle_webhook( + self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None + ) -> Dict[str, Any]: """Handle incoming webhook from Slack.""" import logging - + logger = logging.getLogger(__name__) - + try: # Slack webhooks typically use challenge-response for URL verification if payload.get("type") == "url_verification": diff --git a/app/integrations/trello.py b/app/integrations/trello.py index 3d8ff0f1..0298b886 100644 --- a/app/integrations/trello.py +++ b/app/integrations/trello.py @@ -3,14 +3,16 @@ Sync boards, lists, and cards with Trello. """ -from typing import Dict, Any, Optional, List +import base64 +import hashlib +import hmac +import os from datetime import datetime, timedelta -from app.integrations.base import BaseConnector +from typing import Any, Dict, List, Optional + import requests -import os -import hmac -import hashlib -import base64 + +from app.integrations.base import BaseConnector class TrelloConnector(BaseConnector): @@ -119,8 +121,8 @@ def test_connection(self) -> Dict[str, Any]: def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync boards and cards with Trello.""" - from app.models import Project, Task from app import db + from app.models import Project, Task try: from app.models import Settings @@ -134,8 +136,12 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: return {"success": False, "message": "Trello credentials not configured"} # Get sync direction from config - sync_direction = self.integration.config.get("sync_direction", "trello_to_timetracker") if self.integration else "trello_to_timetracker" - + sync_direction = ( + self.integration.config.get("sync_direction", "trello_to_timetracker") + if self.integration + else "trello_to_timetracker" + ) + if sync_direction in ("trello_to_timetracker", "bidirectional"): trello_result = self._sync_trello_to_timetracker(api_key, token) # If bidirectional, also sync TimeTracker to Trello @@ -145,7 +151,8 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if trello_result.get("success") and tracker_result.get("success"): return { "success": True, - "synced_count": trello_result.get("synced_count", 0) + tracker_result.get("synced_count", 0), + "synced_count": trello_result.get("synced_count", 0) + + tracker_result.get("synced_count", 0), "errors": trello_result.get("errors", []) + tracker_result.get("errors", []), "message": f"Bidirectional sync: Trelloโ†’TimeTracker: {trello_result.get('synced_count', 0)} items | TimeTrackerโ†’Trello: {tracker_result.get('synced_count', 0)} items", } @@ -154,22 +161,25 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: elif tracker_result.get("success"): return tracker_result else: - return {"success": False, "message": f"Both sync directions failed. Trelloโ†’TimeTracker: {trello_result.get('message')}, TimeTrackerโ†’Trello: {tracker_result.get('message')}"} + return { + "success": False, + "message": f"Both sync directions failed. Trelloโ†’TimeTracker: {trello_result.get('message')}, TimeTrackerโ†’Trello: {tracker_result.get('message')}", + } return trello_result - + # Handle TimeTracker to Trello sync if sync_direction == "timetracker_to_trello": return self._sync_timetracker_to_trello(api_key, token) - + return {"success": False, "message": f"Unknown sync direction: {sync_direction}"} except Exception as e: return {"success": False, "message": f"Sync failed: {str(e)}"} - + def _sync_trello_to_timetracker(self, api_key: str, token: str) -> Dict[str, Any]: """Sync Trello boards and cards to TimeTracker projects and tasks.""" - from app.models import Project, Task from app import db + from app.models import Project, Task synced_count = 0 errors = [] @@ -181,7 +191,7 @@ def _sync_trello_to_timetracker(self, api_key: str, token: str) -> Dict[str, Any if boards_response.status_code == 200: boards = boards_response.json() - + # Filter by board_ids if configured board_ids = self.integration.config.get("board_ids", []) if self.integration else [] if board_ids: @@ -190,9 +200,7 @@ def _sync_trello_to_timetracker(self, api_key: str, token: str) -> Dict[str, Any for board in boards: try: # Create or update project from board - project = Project.query.filter_by( - user_id=self.integration.user_id, name=board.get("name") - ).first() + project = Project.query.filter_by(user_id=self.integration.user_id, name=board.get("name")).first() if not project: project = Project( @@ -253,69 +261,67 @@ def _sync_trello_to_timetracker(self, api_key: str, token: str) -> Dict[str, Any db.session.commit() return {"success": True, "synced_count": synced_count, "errors": errors} - + def _sync_timetracker_to_trello(self, api_key: str, token: str) -> Dict[str, Any]: """Sync TimeTracker tasks to Trello cards.""" - from app.models import Project, Task from app import db + from app.models import Project, Task synced_count = 0 errors = [] # Get all projects that have Trello board IDs projects = Project.query.filter_by(user_id=self.integration.user_id, status="active").all() - + for project in projects: # Check if project has Trello board ID trello_board_id = None if hasattr(project, "metadata") and project.metadata: trello_board_id = project.metadata.get("trello_board_id") - + if not trello_board_id: # Try to find or create board board_name = project.name boards_response = requests.get( - f"{self.BASE_URL}/members/me/boards", - params={"key": api_key, "token": token, "filter": "open"} + f"{self.BASE_URL}/members/me/boards", params={"key": api_key, "token": token, "filter": "open"} ) - + if boards_response.status_code == 200: boards = boards_response.json() matching_board = next((b for b in boards if b.get("name") == board_name), None) - + if matching_board: trello_board_id = matching_board.get("id") else: # Create new board (optional - might require additional permissions) try: create_response = requests.post( - f"{self.BASE_URL}/boards", - params={"key": api_key, "token": token, "name": board_name} + f"{self.BASE_URL}/boards", params={"key": api_key, "token": token, "name": board_name} ) if create_response.status_code == 200: trello_board_id = create_response.json().get("id") except Exception as e: errors.append(f"Could not create Trello board for project {project.name}: {str(e)}") continue - + if trello_board_id: if not hasattr(project, "metadata") or not project.metadata: project.metadata = {} project.metadata["trello_board_id"] = trello_board_id - + if not trello_board_id: continue - + # Get lists for this board lists_response = requests.get( f"{self.BASE_URL}/boards/{trello_board_id}/lists", - params={"key": api_key, "token": token, "filter": "open"} + params={"key": api_key, "token": token, "filter": "open"}, ) - + if lists_response.status_code != 200: errors.append(f"Could not get lists for board {project.name}") continue - + lists = lists_response.json() # Create a mapping of status to list ID status_to_list = {} @@ -329,25 +335,25 @@ def _sync_timetracker_to_trello(self, api_key: str, token: str) -> Dict[str, Any status_to_list["done"] = lst.get("id") elif "review" in list_name: status_to_list["review"] = lst.get("id") - + # Default to first list if no mapping found default_list_id = lists[0].get("id") if lists else None - + # Get tasks for this project tasks = Task.query.filter_by(project_id=project.id).all() - + for task in tasks: try: # Check if task already has Trello card ID trello_card_id = None if hasattr(task, "metadata") and task.metadata: trello_card_id = task.metadata.get("trello_card_id") - + # Determine target list target_list_id = status_to_list.get(task.status, default_list_id) if not target_list_id: continue - + if trello_card_id: # Update existing card update_data = { @@ -358,12 +364,14 @@ def _sync_timetracker_to_trello(self, api_key: str, token: str) -> Dict[str, Any update_response = requests.put( f"{self.BASE_URL}/cards/{trello_card_id}", params={"key": api_key, "token": token}, - json=update_data + json=update_data, ) if update_response.status_code == 200: synced_count += 1 else: - errors.append(f"Failed to update Trello card for task {task.id}: {update_response.status_code}") + errors.append( + f"Failed to update Trello card for task {task.id}: {update_response.status_code}" + ) else: # Create new card create_data = { @@ -372,29 +380,29 @@ def _sync_timetracker_to_trello(self, api_key: str, token: str) -> Dict[str, Any "idList": target_list_id, } create_response = requests.post( - f"{self.BASE_URL}/cards", - params={"key": api_key, "token": token}, - json=create_data + f"{self.BASE_URL}/cards", params={"key": api_key, "token": token}, json=create_data ) if create_response.status_code == 200: card_data = create_response.json() trello_card_id = card_data.get("id") - + # Store Trello card ID in task metadata if not hasattr(task, "metadata") or not task.metadata: task.metadata = {} task.metadata["trello_card_id"] = trello_card_id task.metadata["trello_list_id"] = target_list_id - + synced_count += 1 else: - errors.append(f"Failed to create Trello card for task {task.id}: {create_response.status_code}") - + errors.append( + f"Failed to create Trello card for task {task.id}: {create_response.status_code}" + ) + except Exception as e: errors.append(f"Error syncing task {task.id} to Trello: {str(e)}") - + db.session.commit() - + return {"success": True, "synced_count": synced_count, "errors": errors} def _map_trello_list_to_status(self, list_id: str) -> str: diff --git a/app/integrations/xero.py b/app/integrations/xero.py index 66004694..e74430d6 100644 --- a/app/integrations/xero.py +++ b/app/integrations/xero.py @@ -3,13 +3,15 @@ Sync invoices, expenses, and payments with Xero. """ -from typing import Dict, Any, Optional, List -from datetime import datetime, timedelta -from app.integrations.base import BaseConnector -import requests -import os import base64 import logging +import os +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +import requests + +from app.integrations.base import BaseConnector logger = logging.getLogger(__name__) @@ -38,7 +40,13 @@ def get_authorization_url(self, redirect_uri: str, state: str = None) -> str: if not client_id: raise ValueError("XERO_CLIENT_ID not configured") - scopes = ["accounting.invoices", "accounting.payments", "accounting.contacts", "accounting.settings", "offline_access"] + scopes = [ + "accounting.invoices", + "accounting.payments", + "accounting.contacts", + "accounting.settings", + "offline_access", + ] auth_url = "https://login.xero.com/identity/connect/authorize" params = { @@ -214,8 +222,8 @@ def _api_request( def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: """Sync invoices and expenses with Xero""" - from app.models import Invoice, Expense from app import db + from app.models import Expense, Invoice try: tenant_id = self.integration.config.get("tenant_id") @@ -250,7 +258,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: # Sync expenses (create as expenses in Xero) if sync_type == "full" or sync_type == "expenses": - expenses = Expense.query.filter(Expense.expense_date >= datetime.utcnow().date() - timedelta(days=90)).all() + expenses = Expense.query.filter( + Expense.expense_date >= datetime.utcnow().date() - timedelta(days=90) + ).all() for expense in expenses: try: @@ -258,7 +268,9 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]: if xero_expense: if not hasattr(expense, "metadata") or not expense.metadata: expense.metadata = {} - expense.metadata["xero_expense_id"] = xero_expense.get("ExpenseClaims", [{}])[0].get("ExpenseClaimID") + expense.metadata["xero_expense_id"] = xero_expense.get("ExpenseClaims", [{}])[0].get( + "ExpenseClaimID" + ) synced_count += 1 except Exception as e: errors.append(f"Error syncing expense {expense.id}: {str(e)}") @@ -275,18 +287,18 @@ def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Op # Get customer mapping from integration config or invoice metadata contact_mapping = self.integration.config.get("contact_mappings", {}) if self.integration else {} item_mapping = self.integration.config.get("item_mappings", {}) if self.integration else {} - + # Try to get Xero contact ID from mapping or metadata contact_id = None contact_name = invoice.client.name if invoice.client else "Unknown" - + if invoice.client_id: # Check mapping first contact_id = contact_mapping.get(str(invoice.client_id)) # Fallback to invoice metadata if not contact_id and hasattr(invoice, "metadata") and invoice.metadata: contact_id = invoice.metadata.get("xero_contact_id") - + # Build Xero invoice structure xero_invoice = { "Type": "ACCREC", @@ -296,7 +308,7 @@ def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Op ), "LineItems": [], } - + # Add contact - use ID if available, otherwise use name if contact_id: xero_invoice["Contact"] = {"ContactID": contact_id} @@ -308,18 +320,18 @@ def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Op for item in invoice.items: # Try to get Xero item code from mapping item_code = item_mapping.get(str(item.id)) or item_mapping.get(item.description, {}).get("code") - + line_item = { "Description": item.description, "Quantity": float(item.quantity), "UnitAmount": float(item.unit_price), "LineAmount": float(item.quantity * item.unit_price), } - + # Add item code if available if item_code: line_item["ItemCode"] = item_code - + xero_invoice["LineItems"].append(line_item) endpoint = "/api.xro/2.0/Invoices" @@ -329,15 +341,17 @@ def _create_xero_expense(self, expense, access_token: str, tenant_id: str) -> Op """Create expense in Xero""" # Get account mapping from integration config account_mapping = self.integration.config.get("account_mappings", {}) if self.integration else {} - default_expense_account = self.integration.config.get("default_expense_account_code", "200") if self.integration else "200" - + default_expense_account = ( + self.integration.config.get("default_expense_account_code", "200") if self.integration else "200" + ) + # Try to get account code from expense category mapping or use default account_code = default_expense_account if expense.category_id: account_code = account_mapping.get(str(expense.category_id), default_expense_account) elif hasattr(expense, "metadata") and expense.metadata: account_code = expense.metadata.get("xero_account_code", default_expense_account) - + # Build Xero expense structure xero_expense = { "Date": expense.date.strftime("%Y-%m-%d") if expense.date else datetime.utcnow().strftime("%Y-%m-%d"), @@ -394,8 +408,20 @@ def get_config_schema(self) -> Dict[str, Any]: "default": ["invoices", "expenses"], "description": "Select which items to synchronize", }, - {"name": "sync_invoices", "type": "boolean", "label": "Sync Invoices", "default": True, "description": "Enable invoice synchronization"}, - {"name": "sync_expenses", "type": "boolean", "label": "Sync Expenses", "default": True, "description": "Enable expense synchronization"}, + { + "name": "sync_invoices", + "type": "boolean", + "label": "Sync Invoices", + "default": True, + "description": "Enable invoice synchronization", + }, + { + "name": "sync_expenses", + "type": "boolean", + "label": "Sync Expenses", + "default": True, + "description": "Enable expense synchronization", + }, { "name": "auto_sync", "type": "boolean", @@ -431,7 +457,7 @@ def get_config_schema(self) -> Dict[str, Any]: "required": False, "placeholder": '{"1": "contact-uuid-123", "2": "contact-uuid-456"}', "description": "JSON mapping of TimeTracker client IDs to Xero Contact IDs", - "help": "Map your TimeTracker clients to Xero contacts. Format: {\"timetracker_client_id\": \"xero_contact_id\"}", + "help": 'Map your TimeTracker clients to Xero contacts. Format: {"timetracker_client_id": "xero_contact_id"}', }, { "name": "item_mappings", @@ -462,7 +488,14 @@ def get_config_schema(self) -> Dict[str, Any]: { "title": "Sync Settings", "description": "Configure what and how to sync", - "fields": ["sync_direction", "sync_items", "sync_invoices", "sync_expenses", "auto_sync", "sync_interval"], + "fields": [ + "sync_direction", + "sync_items", + "sync_invoices", + "sync_expenses", + "auto_sync", + "sync_interval", + ], }, { "title": "Data Mapping", diff --git a/app/models/__init__.py b/app/models/__init__.py index 3c17a67c..6d03d014 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,96 +1,96 @@ -from .user import User -from .project import Project -from .time_entry import TimeEntry -from .task import Task -from .settings import Settings -from .invoice import Invoice, InvoiceItem -from .invoice_template import InvoiceTemplate -from .currency import Currency, ExchangeRate -from .tax_rule import TaxRule -from .payments import Payment, CreditNote, InvoiceReminderSchedule -from .reporting import SavedReportView, ReportEmailSchedule +from .activity import Activity +from .api_token import ApiToken +from .audit_log import AuditLog +from .budget_alert import BudgetAlert +from .calendar_event import CalendarEvent +from .calendar_integration import CalendarIntegration, CalendarSyncEvent from .client import Client +from .client_attachment import ClientAttachment +from .client_note import ClientNote +from .client_notification import ClientNotification, ClientNotificationPreferences, NotificationType +from .client_portal_customization import ClientPortalCustomization from .client_prepaid_consumption import ClientPrepaidConsumption -from .task_activity import TaskActivity +from .client_time_approval import ClientApprovalPolicy, ClientApprovalStatus, ClientTimeApproval +from .comment import Comment +from .comment_attachment import CommentAttachment +from .contact import Contact +from .contact_communication import ContactCommunication +from .currency import Currency, ExchangeRate +from .custom_field_definition import CustomFieldDefinition +from .custom_report import CustomReportConfig +from .deal import Deal +from .deal_activity import DealActivity +from .donation_interaction import DonationInteraction +from .expense import Expense from .expense_category import ExpenseCategory -from .mileage import Mileage -from .per_diem import PerDiem, PerDiemRate +from .expense_gps import MileageTrack from .extra_good import ExtraGood -from .comment import Comment from .focus_session import FocusSession -from .recurring_block import RecurringBlock -from .rate_override import RateOverride -from .saved_filter import SavedFilter -from .project_cost import ProjectCost -from .kanban_column import KanbanColumn -from .time_entry_template import TimeEntryTemplate -from .activity import Activity -from .user_favorite_project import UserFavoriteProject -from .user_client import UserClient -from .client_note import ClientNote -from .weekly_time_goal import WeeklyTimeGoal -from .expense import Expense -from .permission import Permission, Role -from .api_token import ApiToken -from .calendar_event import CalendarEvent -from .budget_alert import BudgetAlert -from .import_export import DataImport, DataExport -from .invoice_pdf_template import InvoicePDFTemplate -from .audit_log import AuditLog -from .recurring_invoice import RecurringInvoice +from .gamification import Badge, Leaderboard, LeaderboardEntry, UserBadge +from .import_export import DataExport, DataImport +from .integration import Integration, IntegrationCredential, IntegrationEvent +from .integration_external_event_link import IntegrationExternalEventLink +from .invoice import Invoice, InvoiceItem +from .invoice_approval import InvoiceApproval from .invoice_email import InvoiceEmail +from .invoice_image import InvoiceImage +from .invoice_pdf_template import InvoicePDFTemplate from .invoice_peppol import InvoicePeppolTransmission -from .webhook import Webhook, WebhookDelivery +from .invoice_template import InvoiceTemplate +from .issue import Issue +from .kanban_column import KanbanColumn +from .lead import Lead +from .lead_activity import LeadActivity +from .link_template import LinkTemplate +from .mileage import Mileage +from .payment_gateway import PaymentGateway, PaymentTransaction +from .payments import CreditNote, InvoiceReminderSchedule, Payment +from .per_diem import PerDiem, PerDiemRate +from .permission import Permission, Role +from .project import Project +from .project_attachment import ProjectAttachment +from .project_cost import ProjectCost +from .project_stock_allocation import ProjectStockAllocation +from .project_template import ProjectTemplate +from .purchase_order import PurchaseOrder, PurchaseOrderItem from .push_subscription import PushSubscription from .quote import Quote, QuoteItem, QuotePDFTemplate from .quote_attachment import QuoteAttachment -from .project_attachment import ProjectAttachment -from .client_attachment import ClientAttachment -from .comment_attachment import CommentAttachment -from .invoice_image import InvoiceImage from .quote_image import QuoteImage from .quote_template import QuoteTemplate from .quote_version import QuoteVersion -from .warehouse import Warehouse +from .rate_override import RateOverride +from .recurring_block import RecurringBlock +from .recurring_invoice import RecurringInvoice +from .recurring_task import RecurringTask +from .reporting import ReportEmailSchedule, SavedReportView +from .salesman_email_mapping import SalesmanEmailMapping +from .saved_filter import SavedFilter +from .settings import Settings from .stock_item import StockItem -from .warehouse_stock import WarehouseStock +from .stock_lot import StockLot, StockLotAllocation from .stock_movement import StockMovement from .stock_reservation import StockReservation -from .stock_lot import StockLot, StockLotAllocation -from .project_stock_allocation import ProjectStockAllocation from .supplier import Supplier from .supplier_stock_item import SupplierStockItem -from .purchase_order import PurchaseOrder, PurchaseOrderItem -from .contact import Contact -from .contact_communication import ContactCommunication -from .deal import Deal -from .deal_activity import DealActivity -from .lead import Lead -from .lead_activity import LeadActivity -from .project_template import ProjectTemplate -from .invoice_approval import InvoiceApproval -from .payment_gateway import PaymentGateway, PaymentTransaction -from .calendar_integration import CalendarIntegration, CalendarSyncEvent -from .integration import Integration, IntegrationCredential, IntegrationEvent -from .integration_external_event_link import IntegrationExternalEventLink -from .workflow import WorkflowRule, WorkflowExecution -from .time_entry_approval import TimeEntryApproval, ApprovalPolicy, ApprovalStatus +from .task import Task +from .task_activity import TaskActivity +from .tax_rule import TaxRule +from .team_chat import ChatChannel, ChatChannelMember, ChatMessage, ChatReadReceipt +from .time_entry import TimeEntry +from .time_entry_approval import ApprovalPolicy, ApprovalStatus, TimeEntryApproval +from .time_entry_template import TimeEntryTemplate +from .time_off import CompanyHoliday, LeaveType, TimeOffRequest, TimeOffRequestStatus from .timesheet_period import TimesheetPeriod, TimesheetPeriodStatus from .timesheet_policy import TimesheetPolicy -from .time_off import LeaveType, TimeOffRequest, TimeOffRequestStatus, CompanyHoliday -from .recurring_task import RecurringTask -from .client_portal_customization import ClientPortalCustomization -from .team_chat import ChatChannel, ChatMessage, ChatChannelMember, ChatReadReceipt -from .client_time_approval import ClientTimeApproval, ClientApprovalPolicy, ClientApprovalStatus -from .custom_report import CustomReportConfig -from .gamification import Badge, UserBadge, Leaderboard, LeaderboardEntry -from .expense_gps import MileageTrack -from .link_template import LinkTemplate -from .custom_field_definition import CustomFieldDefinition -from .salesman_email_mapping import SalesmanEmailMapping -from .issue import Issue -from .donation_interaction import DonationInteraction -from .client_notification import ClientNotification, ClientNotificationPreferences, NotificationType +from .user import User +from .user_client import UserClient +from .user_favorite_project import UserFavoriteProject +from .warehouse import Warehouse +from .warehouse_stock import WarehouseStock +from .webhook import Webhook, WebhookDelivery +from .weekly_time_goal import WeeklyTimeGoal +from .workflow import WorkflowExecution, WorkflowRule __all__ = [ "User", diff --git a/app/models/activity.py b/app/models/activity.py index 1fcb57c0..83088259 100644 --- a/app/models/activity.py +++ b/app/models/activity.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/api_token.py b/app/models/api_token.py index 49db7e4c..532bd5ec 100644 --- a/app/models/api_token.py +++ b/app/models/api_token.py @@ -2,9 +2,11 @@ import secrets from datetime import datetime, timedelta -from app import db + from sqlalchemy.orm import relationship +from app import db + class ApiToken(db.Model): """API Token for authenticating REST API requests""" diff --git a/app/models/audit_log.py b/app/models/audit_log.py index 98aed83b..5e0bd92c 100644 --- a/app/models/audit_log.py +++ b/app/models/audit_log.py @@ -1,7 +1,8 @@ +import json from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import json class AuditLog(db.Model): @@ -122,7 +123,7 @@ def log_change( # Encode values as JSON if they're not already strings old_val_str = cls._encode_value(old_value) new_val_str = cls._encode_value(new_value) - + # entity_metadata is stored as JSON type, so pass dict/list directly (not encoded) # full_old_state and full_new_state are Text columns, so encode as JSON strings full_old_str = cls._encode_value(full_old_state) if full_old_state else None @@ -166,8 +167,15 @@ def log_change( logger = logging.getLogger(__name__) # Check if it's a table doesn't exist error error_str = str(e).lower() - if "does not exist" in error_str or "no such table" in error_str or "relation" in error_str and "does not exist" in error_str: - logger.warning(f"audit_logs table does not exist - run migration 044_add_audit_logs_table.py. Error: {e}") + if ( + "does not exist" in error_str + or "no such table" in error_str + or "relation" in error_str + and "does not exist" in error_str + ): + logger.warning( + f"audit_logs table does not exist - run migration 044_add_audit_logs_table.py. Error: {e}" + ) else: logger.warning(f"Failed to log audit change (non-critical): {e}") diff --git a/app/models/budget_alert.py b/app/models/budget_alert.py index c5ffdf44..ecb5221a 100644 --- a/app/models/budget_alert.py +++ b/app/models/budget_alert.py @@ -1,4 +1,5 @@ from datetime import datetime, timedelta + from app import db diff --git a/app/models/calendar_event.py b/app/models/calendar_event.py index fea66372..a67b4229 100644 --- a/app/models/calendar_event.py +++ b/app/models/calendar_event.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone @@ -133,9 +134,10 @@ def get_events_in_range(user_id, start_date, end_date, include_tasks=False, incl Returns: Dictionary with events, tasks, and time entries """ - from app.models import Task, TimeEntry import logging + from app.models import Task, TimeEntry + logger = logging.getLogger(__name__) print(f"\n{'*'*80}") @@ -240,18 +242,20 @@ def get_events_in_range(user_id, start_date, end_date, include_tasks=False, incl ).first() if active_timer and active_timer.start_time and start_date <= active_timer.start_time <= end_date: now_end = now_in_app_timezone() - result["time_entries"].append({ - "id": active_timer.id, - "title": "Time: " + (active_timer.project.name if active_timer.project else "Unknown"), - "start": _isoformat_calendar(active_timer.start_time), - "end": _isoformat_calendar(now_end), - "projectId": active_timer.project_id, - "taskId": active_timer.task_id, - "notes": active_timer.notes, - "type": "time_entry", - "source": getattr(active_timer, "source", None), - "is_running": True, - }) + result["time_entries"].append( + { + "id": active_timer.id, + "title": "Time: " + (active_timer.project.name if active_timer.project else "Unknown"), + "start": _isoformat_calendar(active_timer.start_time), + "end": _isoformat_calendar(now_end), + "projectId": active_timer.project_id, + "taskId": active_timer.task_id, + "notes": active_timer.notes, + "type": "time_entry", + "source": getattr(active_timer, "source", None), + "is_running": True, + } + ) else: print(f"MODEL - Not including time entries (include_time_entries=False)") logger.info("Not including time entries (include_time_entries=False)") diff --git a/app/models/calendar_integration.py b/app/models/calendar_integration.py index a66b5027..9267794f 100644 --- a/app/models/calendar_integration.py +++ b/app/models/calendar_integration.py @@ -1,6 +1,7 @@ """Calendar integration models""" from datetime import datetime + from app import db diff --git a/app/models/client.py b/app/models/client.py index 5597a3f2..3149f18d 100644 --- a/app/models/client.py +++ b/app/models/client.py @@ -1,11 +1,14 @@ +import json +import secrets from datetime import datetime, timedelta from decimal import Decimal -from werkzeug.security import generate_password_hash, check_password_hash + from sqlalchemy.orm.attributes import flag_modified +from werkzeug.security import check_password_hash, generate_password_hash + from app import db + from .client_prepaid_consumption import ClientPrepaidConsumption -import secrets -import json class Client(db.Model): @@ -33,7 +36,9 @@ class Client(db.Model): 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 - portal_issues_enabled = db.Column(db.Boolean, default=True, nullable=False) # Enable/disable issue reporting in portal + portal_issues_enabled = db.Column( + db.Boolean, default=True, nullable=False + ) # Enable/disable issue reporting in portal # Custom fields for flexible data storage (e.g., debtor_number, ERP IDs, etc.) custom_fields = db.Column(db.JSON, nullable=True) @@ -310,8 +315,8 @@ def get_portal_data(self): if not self.has_portal_access: return None - from .project import Project from .invoice import Invoice + from .project import Project from .time_entry import TimeEntry # Get active projects for this client diff --git a/app/models/client_attachment.py b/app/models/client_attachment.py index 642ae7bb..d6b56abd 100644 --- a/app/models/client_attachment.py +++ b/app/models/client_attachment.py @@ -1,7 +1,8 @@ +import os from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import os def local_now(): @@ -130,4 +131,3 @@ def get_client_attachments(cls, client_id, include_client_visible=True): query = query.filter_by(is_visible_to_client=False) return query.order_by(cls.uploaded_at.desc()).all() - diff --git a/app/models/client_note.py b/app/models/client_note.py index 2157824c..1a74dad6 100644 --- a/app/models/client_note.py +++ b/app/models/client_note.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/client_notification.py b/app/models/client_notification.py index 835ec97a..2f362eba 100644 --- a/app/models/client_notification.py +++ b/app/models/client_notification.py @@ -2,14 +2,16 @@ Client Notification models for client portal notifications """ +import enum from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import enum class NotificationType(enum.Enum): """Client notification types""" + INVOICE_CREATED = "invoice_created" INVOICE_PAID = "invoice_paid" INVOICE_OVERDUE = "invoice_overdue" @@ -30,28 +32,32 @@ class ClientNotification(db.Model): id = db.Column(db.Integer, primary_key=True) client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) - + # Notification details type = db.Column(db.String(50), nullable=False, index=True) # NotificationType enum value title = db.Column(db.String(200), nullable=False) message = db.Column(db.Text, nullable=False) - + # Link/action link_url = db.Column(db.String(500), nullable=True) # URL to related resource link_text = db.Column(db.String(100), nullable=True) # Text for the link - + # Status is_read = db.Column(db.Boolean, default=False, nullable=False, index=True) read_at = db.Column(db.DateTime, nullable=True) - + # Metadata (renamed from 'metadata' to avoid SQLAlchemy reserved word conflict) extra_data = db.Column(db.JSON, nullable=True) # Additional data (invoice_id, project_id, etc.) - + # Timestamps created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False, index=True) - + # Relationships - client = db.relationship("Client", backref=db.backref("notifications", lazy="dynamic", order_by="desc(ClientNotification.created_at)"), passive_deletes=True) + client = db.relationship( + "Client", + backref=db.backref("notifications", lazy="dynamic", order_by="desc(ClientNotification.created_at)"), + passive_deletes=True, + ) def __repr__(self): return f"" @@ -95,8 +101,10 @@ class ClientNotificationPreferences(db.Model): __tablename__ = "client_notification_preferences" id = db.Column(db.Integer, primary_key=True) - client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) - + client_id = db.Column( + db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, unique=True, index=True + ) + # Email preferences email_enabled = db.Column(db.Boolean, default=True, nullable=False) email_invoice_created = db.Column(db.Boolean, default=True, nullable=False) @@ -107,16 +115,18 @@ class ClientNotificationPreferences(db.Model): email_time_entry_approval = db.Column(db.Boolean, default=True, nullable=False) email_project_status_change = db.Column(db.Boolean, default=False, nullable=False) email_quote_available = db.Column(db.Boolean, default=True, nullable=False) - + # In-app preferences in_app_enabled = 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) - + # Relationships - client = db.relationship("Client", backref=db.backref("notification_preferences", uselist=False), passive_deletes=True) + client = db.relationship( + "Client", backref=db.backref("notification_preferences", uselist=False), passive_deletes=True + ) def __repr__(self): return f"" @@ -125,7 +135,7 @@ def should_send_email(self, notification_type): """Check if email should be sent for this notification type""" if not self.email_enabled: return False - + type_map = { NotificationType.INVOICE_CREATED: self.email_invoice_created, NotificationType.INVOICE_PAID: self.email_invoice_paid, @@ -136,7 +146,7 @@ def should_send_email(self, notification_type): NotificationType.PROJECT_STATUS_CHANGE: self.email_project_status_change, NotificationType.QUOTE_AVAILABLE: self.email_quote_available, } - + return type_map.get(notification_type, True) def to_dict(self): diff --git a/app/models/client_portal_customization.py b/app/models/client_portal_customization.py index e63fa04d..35577538 100644 --- a/app/models/client_portal_customization.py +++ b/app/models/client_portal_customization.py @@ -4,6 +4,7 @@ """ from datetime import datetime + from app import db diff --git a/app/models/client_time_approval.py b/app/models/client_time_approval.py index 56a10cc8..8bab1569 100644 --- a/app/models/client_time_approval.py +++ b/app/models/client_time_approval.py @@ -3,10 +3,12 @@ Similar to manager approval but for client-side approval """ +import enum from datetime import datetime -from app import db + from sqlalchemy import Enum as SQLEnum -import enum + +from app import db class ClientApprovalStatus(enum.Enum): diff --git a/app/models/comment.py b/app/models/comment.py index 16e660cc..c10cc1d4 100644 --- a/app/models/comment.py +++ b/app/models/comment.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone @@ -18,7 +19,9 @@ class Comment(db.Model): # Author of the comment (nullable for client comments) user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True, index=True) - client_contact_id = db.Column(db.Integer, db.ForeignKey("contacts.id"), nullable=True, index=True) # For client comments + client_contact_id = db.Column( + db.Integer, db.ForeignKey("contacts.id"), nullable=True, index=True + ) # For client comments # Visibility: True = internal team comment, False = client-visible comment is_internal = db.Column(db.Boolean, default=True, nullable=False) @@ -42,7 +45,15 @@ class Comment(db.Model): parent = db.relationship("Comment", remote_side=[id], backref="replies") def __init__( - self, content, user_id=None, client_contact_id=None, project_id=None, task_id=None, quote_id=None, parent_id=None, is_internal=True + self, + content, + user_id=None, + client_contact_id=None, + project_id=None, + task_id=None, + quote_id=None, + parent_id=None, + is_internal=True, ): """Create a comment. @@ -85,14 +96,14 @@ def __repr__(self): target = f"Quote {self.quote_id}" else: target = "Unknown" - + author_name = "Unknown" if self.author: author_name = self.author.username elif self.client_contact: author_name = self.client_contact.full_name - - return f'' + + return f"" @property def is_reply(self): @@ -168,7 +179,7 @@ def to_dict(self): elif self.client_contact: author_name = self.client_contact.full_name author_full_name = self.client_contact.full_name - + result = { "id": self.id, "content": self.content, @@ -189,13 +200,13 @@ def to_dict(self): "is_internal": self.is_internal, "is_client_comment": self.is_client_comment, } - + # Add attachments if relationship exists - if hasattr(self, 'attachments'): + if hasattr(self, "attachments"): result["attachments"] = [att.to_dict() for att in self.attachments.all()] else: result["attachments"] = [] - + return result @classmethod diff --git a/app/models/comment_attachment.py b/app/models/comment_attachment.py index 64b86a31..890e0fde 100644 --- a/app/models/comment_attachment.py +++ b/app/models/comment_attachment.py @@ -1,7 +1,8 @@ +import os from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import os def local_now(): @@ -29,7 +30,9 @@ class CommentAttachment(db.Model): uploaded_at = db.Column(db.DateTime, default=local_now, nullable=False) # Relationships - comment = db.relationship("Comment", backref=db.backref("attachments", lazy="dynamic", cascade="all, delete-orphan")) + comment = db.relationship( + "Comment", backref=db.backref("attachments", lazy="dynamic", cascade="all, delete-orphan") + ) uploader = db.relationship("User", backref="uploaded_comment_attachments") def __init__(self, comment_id, filename, original_filename, file_path, file_size, uploaded_by, **kwargs): diff --git a/app/models/contact.py b/app/models/contact.py index ef2d786b..a3969b57 100644 --- a/app/models/contact.py +++ b/app/models/contact.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/contact_communication.py b/app/models/contact_communication.py index b022947d..39fd82e0 100644 --- a/app/models/contact_communication.py +++ b/app/models/contact_communication.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/currency.py b/app/models/currency.py index b8bb28dc..cce1e085 100644 --- a/app/models/currency.py +++ b/app/models/currency.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/custom_field_definition.py b/app/models/custom_field_definition.py index 1c370090..a59dcb11 100644 --- a/app/models/custom_field_definition.py +++ b/app/models/custom_field_definition.py @@ -1,9 +1,11 @@ """Custom Field Definition model for global custom field management""" from datetime import datetime -from app import db + from sqlalchemy.exc import ProgrammingError +from app import db + class CustomFieldDefinition(db.Model): """Model for storing global custom field definitions that can be used across all clients""" @@ -45,7 +47,7 @@ def to_dict(self): @classmethod def get_active_definitions(cls): """Get all active custom field definitions ordered by order and label. - + Returns empty list if table doesn't exist (migration not run yet). """ try: @@ -55,6 +57,7 @@ def get_active_definitions(cls): if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): try: from flask import current_app + if current_app: current_app.logger.warning( "custom_field_definitions table does not exist. Run migration: flask db upgrade" @@ -73,10 +76,9 @@ def get_active_definitions(cls): # For other database errors, return empty list to prevent breaking the app try: from flask import current_app + if current_app: - current_app.logger.warning( - "Could not query custom_field_definitions. Returning empty list." - ) + current_app.logger.warning("Could not query custom_field_definitions. Returning empty list.") except RuntimeError: pass # No application context # Rollback the failed transaction @@ -89,7 +91,7 @@ def get_active_definitions(cls): @classmethod def get_mandatory_definitions(cls): """Get all active mandatory custom field definitions. - + Returns empty list if table doesn't exist (migration not run yet). """ try: @@ -99,6 +101,7 @@ def get_mandatory_definitions(cls): if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): try: from flask import current_app + if current_app: current_app.logger.warning( "custom_field_definitions table does not exist. Run migration: flask db upgrade" @@ -117,10 +120,9 @@ def get_mandatory_definitions(cls): # For other database errors, return empty list to prevent breaking the app try: from flask import current_app + if current_app: - current_app.logger.warning( - "Could not query custom_field_definitions. Returning empty list." - ) + current_app.logger.warning("Could not query custom_field_definitions. Returning empty list.") except RuntimeError: pass # No application context # Rollback the failed transaction @@ -133,7 +135,7 @@ def get_mandatory_definitions(cls): @classmethod def get_by_key(cls, field_key): """Get a custom field definition by its key. - + Returns None if table doesn't exist (migration not run yet). """ try: @@ -143,6 +145,7 @@ def get_by_key(cls, field_key): if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): try: from flask import current_app + if current_app: current_app.logger.warning( "custom_field_definitions table does not exist. Run migration: flask db upgrade" @@ -161,10 +164,9 @@ def get_by_key(cls, field_key): # For other database errors, return None to prevent breaking the app try: from flask import current_app + if current_app: - current_app.logger.warning( - "Could not query custom_field_definitions. Returning None." - ) + current_app.logger.warning("Could not query custom_field_definitions. Returning None.") except RuntimeError: pass # No application context # Rollback the failed transaction @@ -176,9 +178,10 @@ def get_by_key(cls, field_key): def count_clients_with_value(self): """Count how many clients have a value for this custom field""" - from app.models import Client from sqlalchemy import func - + + from app.models import Client + # Query clients that have this field key in their custom_fields JSON # This works for both SQLite and PostgreSQL count = 0 @@ -188,4 +191,4 @@ def count_clients_with_value(self): # Count only if value is not empty if value and str(value).strip(): count += 1 - return count \ No newline at end of file + return count diff --git a/app/models/custom_report.py b/app/models/custom_report.py index ffd2cf07..f2ee28c0 100644 --- a/app/models/custom_report.py +++ b/app/models/custom_report.py @@ -3,6 +3,7 @@ """ from datetime import datetime + from app import db diff --git a/app/models/deal.py b/app/models/deal.py index a37bcc8c..a23e5b60 100644 --- a/app/models/deal.py +++ b/app/models/deal.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/deal_activity.py b/app/models/deal_activity.py index 7bead5ee..830da9df 100644 --- a/app/models/deal_activity.py +++ b/app/models/deal_activity.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/donation_interaction.py b/app/models/donation_interaction.py index 2e250b50..fa84e09a 100644 --- a/app/models/donation_interaction.py +++ b/app/models/donation_interaction.py @@ -14,6 +14,7 @@ """ from datetime import datetime, timedelta + from app import db @@ -33,15 +34,15 @@ class DonationInteraction(db.Model): # A/B test variant for experiments (e.g. control | key_first | cta_alt) variant = db.Column(db.String(50), nullable=True) - + # User metrics at time of interaction (for smart prompts) time_entries_count = db.Column(db.Integer, nullable=True) # Total time entries days_since_signup = db.Column(db.Integer, nullable=True) # Days since user created account total_hours = db.Column(db.Float, nullable=True) # Total hours tracked - + # Timestamps created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - + # Relationships user = db.relationship("User", backref="donation_interactions") @@ -68,7 +69,7 @@ def record_interaction( interaction.time_entries_count = user_metrics.get("time_entries_count") interaction.days_since_signup = user_metrics.get("days_since_signup") interaction.total_hours = user_metrics.get("total_hours") - + db.session.add(interaction) db.session.commit() return interaction @@ -78,16 +79,12 @@ def has_recent_donation_click(user_id: int, days: int = 30) -> bool: """Check if user clicked donation link in last N days""" cutoff = datetime.utcnow() - timedelta(days=days) return ( - DonationInteraction.query.filter_by( - user_id=user_id, interaction_type="banner_clicked" - ) + DonationInteraction.query.filter_by(user_id=user_id, interaction_type="banner_clicked") .filter(DonationInteraction.created_at >= cutoff) .first() is not None ) or ( - DonationInteraction.query.filter_by( - user_id=user_id, interaction_type="link_clicked" - ) + DonationInteraction.query.filter_by(user_id=user_id, interaction_type="link_clicked") .filter(DonationInteraction.created_at >= cutoff) .first() is not None @@ -97,23 +94,22 @@ def has_recent_donation_click(user_id: int, days: int = 30) -> bool: def get_user_engagement_metrics(user_id: int) -> dict: """Get user engagement metrics for smart prompts""" from app.models import TimeEntry, User - + user = User.query.get(user_id) if not user: return {} - + # Days since signup days_since_signup = (datetime.utcnow() - user.created_at).days if user.created_at else 0 - + # Time entries count time_entries_count = TimeEntry.query.filter_by(user_id=user_id).count() - + # Total hours total_hours = user.total_hours if hasattr(user, "total_hours") else 0.0 - + return { "days_since_signup": days_since_signup, "time_entries_count": time_entries_count, "total_hours": total_hours, } - diff --git a/app/models/expense.py b/app/models/expense.py index 41d08f03..335a5dbe 100644 --- a/app/models/expense.py +++ b/app/models/expense.py @@ -1,8 +1,10 @@ from datetime import datetime from decimal import Decimal -from app import db + from sqlalchemy import Index +from app import db + class Expense(db.Model): """Expense tracking model for business expenses""" diff --git a/app/models/expense_category.py b/app/models/expense_category.py index 0a8454e2..cbf1fa27 100644 --- a/app/models/expense_category.py +++ b/app/models/expense_category.py @@ -1,8 +1,10 @@ from datetime import datetime from decimal import Decimal -from app import db + from sqlalchemy import Index +from app import db + class ExpenseCategory(db.Model): """Expense category model with budget tracking""" diff --git a/app/models/expense_gps.py b/app/models/expense_gps.py index b7b184b1..9bed8e02 100644 --- a/app/models/expense_gps.py +++ b/app/models/expense_gps.py @@ -4,9 +4,11 @@ from datetime import datetime from typing import Optional -from app import db + from sqlalchemy import Index +from app import db + class MileageTrack(db.Model): """GPS track for mileage expense calculation""" @@ -79,7 +81,7 @@ def calculate_distance(self): if not all([self.start_latitude, self.start_longitude, self.end_latitude, self.end_longitude]): return None - from math import radians, sin, cos, sqrt, atan2 + from math import atan2, cos, radians, sin, sqrt # Haversine formula R = 6371 # Earth radius in km @@ -108,7 +110,7 @@ def calculate_distance_from_track_points(self) -> Optional[float]: if not self.track_points or len(self.track_points) < 2: return None - from math import radians, sin, cos, sqrt, atan2 + from math import atan2, cos, radians, sin, sqrt R = 6371 # Earth radius in km total_distance = 0.0 diff --git a/app/models/extra_good.py b/app/models/extra_good.py index 7db92caa..16889faa 100644 --- a/app/models/extra_good.py +++ b/app/models/extra_good.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/focus_session.py b/app/models/focus_session.py index 2232cece..a1c23202 100644 --- a/app/models/focus_session.py +++ b/app/models/focus_session.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/gamification.py b/app/models/gamification.py index 2bdb25dc..f1ee4d0a 100644 --- a/app/models/gamification.py +++ b/app/models/gamification.py @@ -3,9 +3,11 @@ """ from datetime import datetime -from app import db + from sqlalchemy import Index +from app import db + class Badge(db.Model): """Badge definition/configuration""" diff --git a/app/models/import_export.py b/app/models/import_export.py index b2b7725a..5c2b94d8 100644 --- a/app/models/import_export.py +++ b/app/models/import_export.py @@ -3,6 +3,7 @@ """ from datetime import datetime + from app import db @@ -66,6 +67,7 @@ def fail(self, error_message=None): except (json.JSONDecodeError, TypeError, ValueError) as e: # If error_log is corrupted, start fresh import logging + logging.getLogger(__name__).warning(f"Could not parse error_log: {e}") pass errors.append({"error": error_message, "timestamp": datetime.utcnow().isoformat()}) @@ -100,6 +102,7 @@ def add_error(self, error_message, record_data=None): except (json.JSONDecodeError, TypeError, ValueError) as e: # If error_log is corrupted, start fresh import logging + logging.getLogger(__name__).warning(f"Could not parse error_log: {e}") pass diff --git a/app/models/integration.py b/app/models/integration.py index d4f8ab0f..ad033a2c 100644 --- a/app/models/integration.py +++ b/app/models/integration.py @@ -3,9 +3,11 @@ """ from datetime import datetime -from app import db + from sqlalchemy import JSON +from app import db + class Integration(db.Model): """Integration model for third-party service connections.""" diff --git a/app/models/integration_external_event_link.py b/app/models/integration_external_event_link.py index ba11977e..be6e9668 100644 --- a/app/models/integration_external_event_link.py +++ b/app/models/integration_external_event_link.py @@ -21,7 +21,9 @@ class IntegrationExternalEventLink(db.Model): integration_id = db.Column( db.Integer, db.ForeignKey("integrations.id", ondelete="CASCADE"), nullable=False, index=True ) - time_entry_id = db.Column(db.Integer, db.ForeignKey("time_entries.id", ondelete="CASCADE"), nullable=False, index=True) + time_entry_id = db.Column( + db.Integer, db.ForeignKey("time_entries.id", ondelete="CASCADE"), nullable=False, index=True + ) # External identifiers external_uid = db.Column(db.String(255), nullable=False, index=True) @@ -29,10 +31,10 @@ class IntegrationExternalEventLink(db.Model): created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) - integration = db.relationship("Integration", backref=db.backref("external_event_links", cascade="all, delete-orphan")) + integration = db.relationship( + "Integration", backref=db.backref("external_event_links", cascade="all, delete-orphan") + ) time_entry = db.relationship("TimeEntry", backref=db.backref("external_event_links", cascade="all, delete-orphan")) def __repr__(self): return f"" - - diff --git a/app/models/invoice.py b/app/models/invoice.py index 0d3b41bd..41923f44 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db @@ -287,16 +288,29 @@ def to_dict(self): @classmethod def generate_invoice_number(cls): """Generate a unique invoice number""" - from datetime import datetime - from app.models import Settings import json import os + from datetime import datetime + + from app.models import Settings # Get settings for invoice prefix and start number settings = Settings.get_settings() # #region agent log try: - log_data = {"location": "invoice.py:291", "message": "Settings.get_settings() returned", "data": {"settings_is_none": settings is None, "settings_has_id": hasattr(settings, "id") and settings.id is not None, "settings_type": type(settings).__name__}, "timestamp": int(datetime.utcnow().timestamp() * 1000), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "A"} + log_data = { + "location": "invoice.py:291", + "message": "Settings.get_settings() returned", + "data": { + "settings_is_none": settings is None, + "settings_has_id": hasattr(settings, "id") and settings.id is not None, + "settings_type": type(settings).__name__, + }, + "timestamp": int(datetime.utcnow().timestamp() * 1000), + "sessionId": "debug-session", + "runId": "run1", + "hypothesisId": "A", + } log_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log") with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_data) + "\n") @@ -311,8 +325,23 @@ def generate_invoice_number(cls): start_number_raw = getattr(settings, "invoice_start_number", 1) # #region agent log try: - log_data = {"location": "invoice.py:296", "message": "Retrieved settings values from object", "data": {"prefix_raw": str(prefix_raw), "start_number_raw": str(start_number_raw), "prefix_raw_type": type(prefix_raw).__name__, "start_number_raw_type": type(start_number_raw).__name__}, "timestamp": int(datetime.utcnow().timestamp() * 1000), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "B"} - log_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log") + log_data = { + "location": "invoice.py:296", + "message": "Retrieved settings values from object", + "data": { + "prefix_raw": str(prefix_raw), + "start_number_raw": str(start_number_raw), + "prefix_raw_type": type(prefix_raw).__name__, + "start_number_raw_type": type(start_number_raw).__name__, + }, + "timestamp": int(datetime.utcnow().timestamp() * 1000), + "sessionId": "debug-session", + "runId": "run1", + "hypothesisId": "B", + } + log_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log" + ) with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_data) + "\n") except (OSError, IOError, TypeError, ValueError): @@ -329,7 +358,19 @@ def generate_invoice_number(cls): start_number = 1 # #region agent log try: - log_data = {"location": "invoice.py:304", "message": "Final prefix and start_number values", "data": {"prefix": str(prefix), "start_number": int(start_number), "settings_was_none": settings is None}, "timestamp": int(datetime.utcnow().timestamp() * 1000), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "C"} + log_data = { + "location": "invoice.py:304", + "message": "Final prefix and start_number values", + "data": { + "prefix": str(prefix), + "start_number": int(start_number), + "settings_was_none": settings is None, + }, + "timestamp": int(datetime.utcnow().timestamp() * 1000), + "sessionId": "debug-session", + "runId": "run1", + "hypothesisId": "C", + } log_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log") with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_data) + "\n") @@ -343,11 +384,7 @@ def generate_invoice_number(cls): search_pattern = f"{prefix}-{date_prefix}-%" # Find the next available number for today with the custom prefix - existing = ( - cls.query.filter(cls.invoice_number.like(search_pattern)) - .order_by(cls.invoice_number.desc()) - .first() - ) + existing = cls.query.filter(cls.invoice_number.like(search_pattern)).order_by(cls.invoice_number.desc()).first() if existing: # Extract the number part and increment diff --git a/app/models/invoice_approval.py b/app/models/invoice_approval.py index a8ea4bf2..eb9bfa03 100644 --- a/app/models/invoice_approval.py +++ b/app/models/invoice_approval.py @@ -1,6 +1,7 @@ """Invoice approval workflow models""" from datetime import datetime + from app import db diff --git a/app/models/invoice_email.py b/app/models/invoice_email.py index 1bad230c..f40f84ad 100644 --- a/app/models/invoice_email.py +++ b/app/models/invoice_email.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/invoice_image.py b/app/models/invoice_image.py index 4bf1ac37..e3f4a27a 100644 --- a/app/models/invoice_image.py +++ b/app/models/invoice_image.py @@ -1,7 +1,8 @@ +import os from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import os def local_now(): diff --git a/app/models/invoice_pdf_template.py b/app/models/invoice_pdf_template.py index 9ccd653b..fd4c0ab7 100644 --- a/app/models/invoice_pdf_template.py +++ b/app/models/invoice_pdf_template.py @@ -4,6 +4,7 @@ """ from datetime import datetime + from app import db @@ -18,7 +19,9 @@ class InvoicePDFTemplate(db.Model): template_css = db.Column(db.Text, nullable=True) # Legacy CSS template (backward compatibility) design_json = db.Column(db.Text, nullable=True) # Konva.js design state template_json = db.Column(db.Text, nullable=True) # ReportLab template JSON (new format) - date_format = db.Column(db.String(50), default="%d.%m.%Y", nullable=False) # Date format for invoices (strftime format) + date_format = db.Column( + db.String(50), default="%d.%m.%Y", nullable=False + ) # Date format for invoices (strftime format) 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) @@ -42,9 +45,10 @@ def get_template(cls, page_size="A4"): template = cls.query.filter_by(page_size=page_size).first() if not template: # Create default template for this size with default JSON - from app.utils.pdf_template_schema import get_default_template import json - + + from app.utils.pdf_template_schema import get_default_template + default_json = get_default_template(page_size) template = cls( page_size=page_size, @@ -53,7 +57,7 @@ def get_template(cls, page_size="A4"): design_json="", template_json=json.dumps(default_json), date_format="%d.%m.%Y", - is_default=True + is_default=True, ) db.session.add(template) try: @@ -64,7 +68,7 @@ def get_template(cls, page_size="A4"): template = cls.query.filter_by(page_size=page_size).first() if not template: raise - + # DON'T call ensure_template_json() here - it may overwrite saved templates # Only validate that template exists - if it has no JSON, it will be handled during export # This prevents overwriting saved custom templates with defaults @@ -83,9 +87,10 @@ def get_default_template(cls): @classmethod def ensure_default_templates(cls): """Ensure all default templates exist""" - from app.utils.pdf_template_schema import get_default_template import json - + + from app.utils.pdf_template_schema import get_default_template + default_sizes = ["A4", "Letter", "Legal", "A3", "A5"] for size in default_sizes: template = cls.query.filter_by(page_size=size).first() @@ -97,7 +102,7 @@ def ensure_default_templates(cls): template_css="", design_json="", template_json=json.dumps(default_json), - is_default=True + is_default=True, ) db.session.add(template) try: @@ -118,20 +123,22 @@ def to_dict(self): "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_template_json(self): """Get template JSON, parsing from string if needed""" if not self.template_json: return None import json + try: return json.loads(self.template_json) except Exception: return None - + def set_template_json(self, template_dict): """Set template JSON from dictionary""" import json + self.template_json = json.dumps(template_dict) if template_dict else None def get_page_dimensions_mm(self): @@ -145,12 +152,13 @@ def get_page_dimensions_px(self, dpi=72): 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} - + def ensure_template_json(self): """Ensure template has valid JSON, generate if missing""" - from flask import current_app import json - + + from flask import current_app + # First check if template_json exists and is not empty if self.template_json and self.template_json.strip(): # Validate that it's valid JSON @@ -158,27 +166,42 @@ def ensure_template_json(self): parsed_json = json.loads(self.template_json) # If it's valid JSON with at least a page property, consider it valid if isinstance(parsed_json, dict) and "page" in parsed_json: - current_app.logger.info(f"[TEMPLATE] Template JSON is valid - PageSize: '{self.page_size}', TemplateID: {self.id}") + current_app.logger.info( + f"[TEMPLATE] Template JSON is valid - PageSize: '{self.page_size}', TemplateID: {self.id}" + ) return # Template JSON is valid, don't overwrite else: - current_app.logger.warning(f"[TEMPLATE] Template JSON exists but missing 'page' property - PageSize: '{self.page_size}', TemplateID: {self.id}") + current_app.logger.warning( + f"[TEMPLATE] Template JSON exists but missing 'page' property - PageSize: '{self.page_size}', TemplateID: {self.id}" + ) except json.JSONDecodeError as e: - current_app.logger.warning(f"[TEMPLATE] Template JSON exists but is invalid JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}") + current_app.logger.warning( + f"[TEMPLATE] Template JSON exists but is invalid JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}" + ) # Invalid JSON - will generate default below - + # Only generate default if template_json is truly None or empty, or invalid if not self.template_json or not self.template_json.strip(): - current_app.logger.warning(f"[TEMPLATE] Generating default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: template_json is missing or empty") + current_app.logger.warning( + f"[TEMPLATE] Generating default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: template_json is missing or empty" + ) else: - current_app.logger.warning(f"[TEMPLATE] Generating default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: existing JSON is invalid") - + current_app.logger.warning( + f"[TEMPLATE] Generating default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: existing JSON is invalid" + ) + from app.utils.pdf_template_schema import get_default_template - + default_json = get_default_template(self.page_size) self.template_json = json.dumps(default_json) try: db.session.commit() - current_app.logger.info(f"[TEMPLATE] Default template JSON saved - PageSize: '{self.page_size}', TemplateID: {self.id}") + current_app.logger.info( + f"[TEMPLATE] Default template JSON saved - PageSize: '{self.page_size}', TemplateID: {self.id}" + ) except Exception as e: - current_app.logger.error(f"[TEMPLATE] Failed to save default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}", exc_info=True) + current_app.logger.error( + f"[TEMPLATE] Failed to save default template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}", + exc_info=True, + ) db.session.rollback() diff --git a/app/models/invoice_peppol.py b/app/models/invoice_peppol.py index 08fad58c..c3bdbae7 100644 --- a/app/models/invoice_peppol.py +++ b/app/models/invoice_peppol.py @@ -63,4 +63,3 @@ def to_dict(self): "created_at": self.created_at.isoformat() if self.created_at else None, "sent_at": self.sent_at.isoformat() if self.sent_at else None, } - diff --git a/app/models/invoice_template.py b/app/models/invoice_template.py index ab97d936..09bd1676 100644 --- a/app/models/invoice_template.py +++ b/app/models/invoice_template.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/issue.py b/app/models/issue.py index 7908eee4..716c6481 100644 --- a/app/models/issue.py +++ b/app/models/issue.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone @@ -12,23 +13,25 @@ class Issue(db.Model): 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=True, index=True) task_id = db.Column(db.Integer, db.ForeignKey("tasks.id"), nullable=True, index=True) - + title = db.Column(db.String(200), nullable=False, index=True) description = db.Column(db.Text, nullable=True) status = db.Column( db.String(20), default="open", nullable=False, index=True ) # 'open', 'in_progress', 'resolved', 'closed', 'cancelled' priority = db.Column(db.String(20), default="medium", nullable=False) # 'low', 'medium', 'high', 'urgent' - + # Client submission info submitted_by_client = db.Column(db.Boolean, default=True, nullable=False) # True if submitted via client portal client_submitter_name = db.Column(db.String(200), nullable=True) # Name of person who submitted (if not a user) client_submitter_email = db.Column(db.String(200), nullable=True) # Email of submitter - + # Internal assignment 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=True, index=True) # Internal user who created/imported - + created_by = db.Column( + db.Integer, db.ForeignKey("users.id"), nullable=True, index=True + ) # Internal user who created/imported + # 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) @@ -155,30 +158,31 @@ def cancel(self): def link_to_task(self, task_id): """Link this issue to a task""" from .task import Task + task = Task.query.get(task_id) if not task: raise ValueError("Task not found") - + # Verify task belongs to same client (through project) if task.project.client_id != self.client_id: raise ValueError("Task must belong to a project from the same client") - + self.task_id = task_id self.updated_at = now_in_app_timezone() db.session.commit() def create_task_from_issue(self, project_id, assigned_to=None, created_by=None): """Create a new task from this issue""" - from .task import Task - # Verify project belongs to same client from .project import Project + from .task import Task + project = Project.query.get(project_id) if not project: raise ValueError("Project not found") if project.client_id != self.client_id: raise ValueError("Project must belong to the same client") - + # Create task task = Task( project_id=project_id, @@ -191,12 +195,12 @@ def create_task_from_issue(self, project_id, assigned_to=None, created_by=None): ) db.session.add(task) db.session.flush() # Get task ID - + # Link issue to task self.task_id = task.id self.updated_at = now_in_app_timezone() db.session.commit() - + return task def reassign(self, user_id): diff --git a/app/models/lead.py b/app/models/lead.py index aea6e3e5..8c36a245 100644 --- a/app/models/lead.py +++ b/app/models/lead.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/lead_activity.py b/app/models/lead_activity.py index 0ca0dea7..a5eb817a 100644 --- a/app/models/lead_activity.py +++ b/app/models/lead_activity.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone diff --git a/app/models/link_template.py b/app/models/link_template.py index 017e7fd0..fb9e1ab8 100644 --- a/app/models/link_template.py +++ b/app/models/link_template.py @@ -1,9 +1,11 @@ """Link Template model for storing URL templates with field placeholders""" from datetime import datetime -from app import db + from sqlalchemy.exc import ProgrammingError +from app import db + class LinkTemplate(db.Model): """Model for storing URL templates that can use custom field values from clients""" @@ -30,7 +32,7 @@ def __repr__(self): def render_url(self, field_value): """Render the URL template with the given field value - + Supports both {value} and %value% placeholder formats """ if not field_value: @@ -67,7 +69,7 @@ def to_dict(self): @classmethod def get_active_templates(cls, field_key=None): """Get active link templates, optionally filtered by field_key. - + Returns empty list if table doesn't exist (migration not run yet). """ try: @@ -80,6 +82,7 @@ def get_active_templates(cls, field_key=None): if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower(): try: from flask import current_app + if current_app: current_app.logger.warning( "link_templates table does not exist. Run migration: flask db upgrade" @@ -98,10 +101,9 @@ def get_active_templates(cls, field_key=None): # For other database errors, return empty list to prevent breaking the app try: from flask import current_app + if current_app: - current_app.logger.warning( - "Could not query link_templates. Returning empty list." - ) + current_app.logger.warning("Could not query link_templates. Returning empty list.") except RuntimeError: pass # No application context # Rollback the failed transaction diff --git a/app/models/mileage.py b/app/models/mileage.py index e84f937d..7505d3da 100644 --- a/app/models/mileage.py +++ b/app/models/mileage.py @@ -1,8 +1,10 @@ from datetime import datetime from decimal import Decimal -from app import db + from sqlalchemy import Index +from app import db + class Mileage(db.Model): """Mileage tracking for business travel""" diff --git a/app/models/payment_gateway.py b/app/models/payment_gateway.py index 7bdf4996..2c4ba8c1 100644 --- a/app/models/payment_gateway.py +++ b/app/models/payment_gateway.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/payments.py b/app/models/payments.py index a73f38c5..3ec78127 100644 --- a/app/models/payments.py +++ b/app/models/payments.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/per_diem.py b/app/models/per_diem.py index 90d429d4..3ca02e92 100644 --- a/app/models/per_diem.py +++ b/app/models/per_diem.py @@ -1,8 +1,10 @@ from datetime import datetime, timedelta from decimal import Decimal -from app import db + from sqlalchemy import Index +from app import db + class PerDiemRate(db.Model): """Per diem rate configuration for different locations""" diff --git a/app/models/permission.py b/app/models/permission.py index 5682791a..610af7fe 100644 --- a/app/models/permission.py +++ b/app/models/permission.py @@ -1,6 +1,7 @@ """Permission model for granular access control""" from datetime import datetime + from app import db diff --git a/app/models/project.py b/app/models/project.py index e1953915..499e428f 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db @@ -359,12 +360,14 @@ def get_rendered_links(self): if field_value: rendered_url = template.render_url(field_value) if rendered_url: - links.append({ - "name": template.name, - "url": rendered_url, - "icon": template.icon, - "description": template.description - }) + links.append( + { + "name": template.name, + "url": rendered_url, + "icon": template.icon, + "description": template.description, + } + ) return links diff --git a/app/models/project_attachment.py b/app/models/project_attachment.py index 8bf3ec99..4076c905 100644 --- a/app/models/project_attachment.py +++ b/app/models/project_attachment.py @@ -1,7 +1,8 @@ +import os from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import os def local_now(): @@ -130,4 +131,3 @@ def get_project_attachments(cls, project_id, include_client_visible=True): query = query.filter_by(is_visible_to_client=False) return query.order_by(cls.uploaded_at.desc()).all() - diff --git a/app/models/project_cost.py b/app/models/project_cost.py index 6274b67c..8394ebfd 100644 --- a/app/models/project_cost.py +++ b/app/models/project_cost.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/project_stock_allocation.py b/app/models/project_stock_allocation.py index 38462a5f..f3d8f588 100644 --- a/app/models/project_stock_allocation.py +++ b/app/models/project_stock_allocation.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/project_template.py b/app/models/project_template.py index b9663870..044d7f5c 100644 --- a/app/models/project_template.py +++ b/app/models/project_template.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/purchase_order.py b/app/models/purchase_order.py index 46a1ed64..a9cb1083 100644 --- a/app/models/purchase_order.py +++ b/app/models/purchase_order.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/push_subscription.py b/app/models/push_subscription.py index c0682416..91258bc9 100644 --- a/app/models/push_subscription.py +++ b/app/models/push_subscription.py @@ -2,10 +2,11 @@ Push Subscription model for storing browser push notification subscriptions. """ +import json from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import json class PushSubscription(db.Model): @@ -15,17 +16,17 @@ class PushSubscription(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - + # Push subscription data (JSON format from browser Push API) endpoint = db.Column(db.Text, nullable=False) # Push service endpoint URL keys = db.Column(db.JSON, nullable=False) # p256dh and auth keys - + # Metadata user_agent = db.Column(db.String(500), nullable=True) # Browser user agent 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_used_at = db.Column(db.DateTime, nullable=True) # Last time subscription was used - + # Relationships user = db.relationship("User", backref="push_subscriptions", lazy="joined") @@ -67,4 +68,3 @@ def get_user_subscriptions(cls, user_id): def find_by_endpoint(cls, user_id, endpoint): """Find a subscription by user and endpoint""" return cls.query.filter_by(user_id=user_id, endpoint=endpoint).first() - diff --git a/app/models/quote.py b/app/models/quote.py index 8361b1c6..03332666 100644 --- a/app/models/quote.py +++ b/app/models/quote.py @@ -1,6 +1,8 @@ from datetime import datetime from decimal import Decimal + from sqlalchemy import and_ + from app import db from app.utils.timezone import now_in_app_timezone @@ -210,6 +212,7 @@ def calculate_due_date_from_payment_terms(self, issue_date=None): 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: @@ -456,7 +459,9 @@ class QuotePDFTemplate(db.Model): template_css = db.Column(db.Text, nullable=True) # Legacy CSS template (backward compatibility) design_json = db.Column(db.Text, nullable=True) # Konva.js design state template_json = db.Column(db.Text, nullable=True) # ReportLab template JSON (new format) - date_format = db.Column(db.String(50), default="%d.%m.%Y", nullable=False) # Date format for quotes (strftime format) + date_format = db.Column( + db.String(50), default="%d.%m.%Y", nullable=False + ) # Date format for quotes (strftime format) 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) @@ -480,15 +485,16 @@ def get_template(cls, page_size="A4"): template = cls.query.filter_by(page_size=page_size).first() if not template: # Create default template for this size with default JSON - from app.utils.pdf_template_schema import get_default_template import json - + + from app.utils.pdf_template_schema import get_default_template + default_json = get_default_template(page_size) template = cls( page_size=page_size, template_json=json.dumps(default_json), date_format="%d.%m.%Y", - is_default=(page_size == "A4") + is_default=(page_size == "A4"), ) db.session.add(template) try: @@ -499,7 +505,7 @@ def get_template(cls, page_size="A4"): template = cls.query.filter_by(page_size=page_size).first() if not template: raise - + # DON'T call ensure_template_json() here - it may overwrite saved templates # Only validate that template exists - if it has no JSON, it will be handled during export # This prevents overwriting saved custom templates with defaults @@ -519,27 +525,30 @@ def get_default_template(cls): template.is_default = True db.session.commit() return template - + def get_template_json(self): """Get template JSON, parsing from string if needed""" if not self.template_json: return None import json + try: return json.loads(self.template_json) except Exception: return None - + def set_template_json(self, template_dict): """Set template JSON from dictionary""" import json + self.template_json = json.dumps(template_dict) if template_dict else None - + def ensure_template_json(self): """Ensure template has valid JSON, generate if missing""" - from flask import current_app import json - + + from flask import current_app + # First check if template_json exists and is not empty if self.template_json and self.template_json.strip(): # Validate that it's valid JSON @@ -547,28 +556,44 @@ def ensure_template_json(self): parsed_json = json.loads(self.template_json) # If it's valid JSON with at least a page property, consider it valid if isinstance(parsed_json, dict) and "page" in parsed_json: - current_app.logger.info(f"[TEMPLATE] Quote template JSON is valid - PageSize: '{self.page_size}', TemplateID: {self.id}") + current_app.logger.info( + f"[TEMPLATE] Quote template JSON is valid - PageSize: '{self.page_size}', TemplateID: {self.id}" + ) return # Template JSON is valid, don't overwrite else: - current_app.logger.warning(f"[TEMPLATE] Quote template JSON exists but missing 'page' property - PageSize: '{self.page_size}', TemplateID: {self.id}") + current_app.logger.warning( + f"[TEMPLATE] Quote template JSON exists but missing 'page' property - PageSize: '{self.page_size}', TemplateID: {self.id}" + ) except json.JSONDecodeError as e: - current_app.logger.warning(f"[TEMPLATE] Quote template JSON exists but is invalid JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}") + current_app.logger.warning( + f"[TEMPLATE] Quote template JSON exists but is invalid JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}" + ) # Invalid JSON - will generate default below - + # Only generate default if template_json is truly None or empty, or invalid if not self.template_json or not self.template_json.strip(): - current_app.logger.warning(f"[TEMPLATE] Generating default quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: template_json is missing or empty") + current_app.logger.warning( + f"[TEMPLATE] Generating default quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: template_json is missing or empty" + ) else: - current_app.logger.warning(f"[TEMPLATE] Generating default quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: existing JSON is invalid") - - from app.utils.pdf_template_schema import get_default_template + current_app.logger.warning( + f"[TEMPLATE] Generating default quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Reason: existing JSON is invalid" + ) + import json - + + from app.utils.pdf_template_schema import get_default_template + default_json = get_default_template(self.page_size) self.template_json = json.dumps(default_json) try: db.session.commit() - current_app.logger.info(f"[TEMPLATE] Default quote template JSON saved - PageSize: '{self.page_size}', TemplateID: {self.id}") + current_app.logger.info( + f"[TEMPLATE] Default quote template JSON saved - PageSize: '{self.page_size}', TemplateID: {self.id}" + ) except Exception as e: - current_app.logger.error(f"[TEMPLATE] Failed to save default quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}", exc_info=True) + current_app.logger.error( + f"[TEMPLATE] Failed to save default quote template JSON - PageSize: '{self.page_size}', TemplateID: {self.id}, Error: {str(e)}", + exc_info=True, + ) db.session.rollback() diff --git a/app/models/quote_attachment.py b/app/models/quote_attachment.py index cd229daf..104ee2e0 100644 --- a/app/models/quote_attachment.py +++ b/app/models/quote_attachment.py @@ -1,7 +1,8 @@ +import os from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import os def local_now(): diff --git a/app/models/quote_image.py b/app/models/quote_image.py index 615276e0..ccd21a9d 100644 --- a/app/models/quote_image.py +++ b/app/models/quote_image.py @@ -1,7 +1,8 @@ +import os from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import os def local_now(): diff --git a/app/models/quote_template.py b/app/models/quote_template.py index f9a721b8..53ee575b 100644 --- a/app/models/quote_template.py +++ b/app/models/quote_template.py @@ -1,7 +1,8 @@ +import json from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import json def local_now(): @@ -119,9 +120,10 @@ def apply_to_quote(self, quote): # Apply default items items = self.items_list if items: - from app.models import QuoteItem from decimal import Decimal + from app.models import QuoteItem + for item_data in items: item = QuoteItem( quote_id=quote.id, diff --git a/app/models/quote_version.py b/app/models/quote_version.py index 1a6d6268..2796e0a7 100644 --- a/app/models/quote_version.py +++ b/app/models/quote_version.py @@ -1,7 +1,8 @@ +import json from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone -import json def local_now(): diff --git a/app/models/rate_override.py b/app/models/rate_override.py index 030b4a4b..36b895e4 100644 --- a/app/models/rate_override.py +++ b/app/models/rate_override.py @@ -1,5 +1,6 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/recurring_block.py b/app/models/recurring_block.py index f364fadf..b624fc98 100644 --- a/app/models/recurring_block.py +++ b/app/models/recurring_block.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/recurring_invoice.py b/app/models/recurring_invoice.py index 45f2c74c..87ba9511 100644 --- a/app/models/recurring_invoice.py +++ b/app/models/recurring_invoice.py @@ -1,6 +1,8 @@ from datetime import datetime, timedelta from decimal import Decimal + from dateutil.relativedelta import relativedelta + from app import db diff --git a/app/models/recurring_task.py b/app/models/recurring_task.py index 159e6992..5cc1f92f 100644 --- a/app/models/recurring_task.py +++ b/app/models/recurring_task.py @@ -3,8 +3,10 @@ Similar to recurring invoices but for tasks """ -from datetime import datetime, date, timedelta +from datetime import date, datetime, timedelta + from dateutil.relativedelta import relativedelta + from app import db diff --git a/app/models/reporting.py b/app/models/reporting.py index 87f33c62..660b2d44 100644 --- a/app/models/reporting.py +++ b/app/models/reporting.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db @@ -12,8 +13,12 @@ class SavedReportView(db.Model): 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 - iterative_report_generation = db.Column(db.Boolean, default=False, nullable=False) # Generate one report per custom field value - iterative_custom_field_name = db.Column(db.String(50), nullable=True) # Custom field name for iteration (e.g., 'salesman') + iterative_report_generation = db.Column( + db.Boolean, default=False, nullable=False + ) # Generate one report per custom field value + iterative_custom_field_name = db.Column( + db.String(50), nullable=True + ) # Custom field name for iteration (e.g., 'salesman') 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) @@ -36,10 +41,14 @@ class ReportEmailSchedule(db.Model): last_run_at = db.Column(db.DateTime, nullable=True) active = db.Column(db.Boolean, default=True, nullable=False) split_by_salesman = db.Column(db.Boolean, default=False, nullable=False) # Split report by salesman - salesman_field_name = db.Column(db.String(50), nullable=True) # Custom field name for salesman (default: 'salesman') + salesman_field_name = db.Column( + db.String(50), nullable=True + ) # Custom field name for salesman (default: 'salesman') email_distribution_mode = db.Column(db.String(20), nullable=True) # 'mapping', 'template', 'single' recipient_email_template = db.Column(db.String(255), nullable=True) # e.g., '{value}@test.de' for template mode - use_last_month_dates = db.Column(db.Boolean, default=False, nullable=False) # For monthly: use previous calendar month as date range + use_last_month_dates = db.Column( + db.Boolean, default=False, nullable=False + ) # For monthly: use previous calendar month as date range 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) diff --git a/app/models/salesman_email_mapping.py b/app/models/salesman_email_mapping.py index 1f47ecfc..0e913de4 100644 --- a/app/models/salesman_email_mapping.py +++ b/app/models/salesman_email_mapping.py @@ -4,7 +4,9 @@ Maps salesman initials (from client custom fields) to email addresses for automated report distribution. """ + from datetime import datetime + from app import db @@ -66,7 +68,7 @@ def get_email_for_initial(cls, initial): """Get email address for a salesman initial""" if not initial: return None - + initial = initial.strip().upper() mapping = cls.query.filter_by(salesman_initial=initial, is_active=True).first() if mapping: @@ -77,4 +79,3 @@ def get_email_for_initial(cls, initial): def get_all_active(cls): """Get all active mappings""" return cls.query.filter_by(is_active=True).order_by(cls.salesman_initial).all() - diff --git a/app/models/saved_filter.py b/app/models/saved_filter.py index d49a3eb5..a036db78 100644 --- a/app/models/saved_filter.py +++ b/app/models/saved_filter.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/settings.py b/app/models/settings.py index 8126aba1..f16e7b01 100644 --- a/app/models/settings.py +++ b/app/models/settings.py @@ -1,6 +1,6 @@ -from datetime import datetime import os import threading +from datetime import datetime from app import db from app.config import Config @@ -16,9 +16,10 @@ def _session_in_flush(session): if getattr(session, "_flushing", False): return True # Fallback: in a transaction and inside a flush context (if exposed) - if getattr(session, "in_transaction", lambda: False)() and getattr( - session, "_current_flush_context", None - ) is not None: + if ( + getattr(session, "in_transaction", lambda: False)() + and getattr(session, "_current_flush_context", None) is not None + ): return True return False except Exception: @@ -32,7 +33,9 @@ class Settings(db.Model): id = db.Column(db.Integer, primary_key=True) timezone = db.Column(db.String(50), default="Europe/Rome", nullable=False) - date_format = db.Column(db.String(20), default="YYYY-MM-DD", nullable=False) # YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, DD.MM.YYYY + date_format = db.Column( + db.String(20), default="YYYY-MM-DD", nullable=False + ) # YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, DD.MM.YYYY time_format = db.Column(db.String(10), default="24h", nullable=False) # 24h or 12h currency = db.Column(db.String(3), default="EUR", nullable=False) rounding_minutes = db.Column(db.Integer, default=1, nullable=False) @@ -513,8 +516,26 @@ def get_settings(cls): # #region agent log try: import json - log_data = {"location": "settings.py:422", "message": "Settings query result", "data": {"settings_is_none": settings is None, "settings_has_id": settings is not None and hasattr(settings, "id") and settings.id is not None, "invoice_prefix": getattr(settings, "invoice_prefix", "MISSING") if settings else "N/A", "invoice_start_number": getattr(settings, "invoice_start_number", "MISSING") if settings else "N/A"}, "timestamp": int(datetime.utcnow().timestamp() * 1000), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "D"} - log_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log") + + log_data = { + "location": "settings.py:422", + "message": "Settings query result", + "data": { + "settings_is_none": settings is None, + "settings_has_id": settings is not None and hasattr(settings, "id") and settings.id is not None, + "invoice_prefix": getattr(settings, "invoice_prefix", "MISSING") if settings else "N/A", + "invoice_start_number": ( + getattr(settings, "invoice_start_number", "MISSING") if settings else "N/A" + ), + }, + "timestamp": int(datetime.utcnow().timestamp() * 1000), + "sessionId": "debug-session", + "runId": "run1", + "hypothesisId": "D", + } + log_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log" + ) with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_data) + "\n") except (OSError, IOError, TypeError, ValueError): @@ -528,11 +549,11 @@ def get_settings(cls): error_str = str(e) # Also check the underlying exception if it's a SQLAlchemy exception underlying_error = "" - if hasattr(e, 'orig'): + if hasattr(e, "orig"): underlying_error = str(e.orig) - elif hasattr(e, '__cause__') and e.__cause__: + elif hasattr(e, "__cause__") and e.__cause__: underlying_error = str(e.__cause__) - + combined_error = f"{error_str} {underlying_error}".lower() is_schema_error = ( "undefinedcolumn" in combined_error @@ -540,7 +561,8 @@ def get_settings(cls): or "no such column" in combined_error or "no such table" in combined_error or ("relation" in combined_error and "does not exist" in combined_error) - or "operationalerror" in combined_error and ("no such table" in combined_error or "does not exist" in combined_error) + or "operationalerror" in combined_error + and ("no such table" in combined_error or "does not exist" in combined_error) ) import logging @@ -599,7 +621,16 @@ def get_settings(cls): # #region agent log try: import json - log_data = {"location": "settings.py:493", "message": "Returning fallback Settings instance", "data": {"fallback": True}, "timestamp": int(datetime.utcnow().timestamp() * 1000), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "E"} + + log_data = { + "location": "settings.py:493", + "message": "Returning fallback Settings instance", + "data": {"fallback": True}, + "timestamp": int(datetime.utcnow().timestamp() * 1000), + "sessionId": "debug-session", + "runId": "run1", + "hypothesisId": "E", + } log_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log") with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_data) + "\n") diff --git a/app/models/stock_item.py b/app/models/stock_item.py index 19a043e9..036fbe0e 100644 --- a/app/models/stock_item.py +++ b/app/models/stock_item.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/stock_lot.py b/app/models/stock_lot.py index eb8cadc3..db962313 100644 --- a/app/models/stock_lot.py +++ b/app/models/stock_lot.py @@ -80,10 +80,7 @@ class StockLotAllocation(db.Model): movement = db.relationship("StockMovement", foreign_keys=[stock_movement_id], backref="lot_allocations") - __table_args__ = ( - db.Index("ix_stock_lot_allocations_move_lot", "stock_movement_id", "stock_lot_id"), - ) + __table_args__ = (db.Index("ix_stock_lot_allocations_move_lot", "stock_movement_id", "stock_lot_id"),) def __repr__(self): return f"" - diff --git a/app/models/stock_movement.py b/app/models/stock_movement.py index 6b5b0a6f..afaaf7f9 100644 --- a/app/models/stock_movement.py +++ b/app/models/stock_movement.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db @@ -104,9 +105,9 @@ def record_movement( Returns: tuple: (StockMovement instance, updated WarehouseStock instance or None) """ - from .warehouse_stock import WarehouseStock from .stock_item import StockItem from .stock_lot import StockLot, StockLotAllocation + from .warehouse_stock import WarehouseStock movement = cls( movement_type=movement_type, @@ -198,7 +199,9 @@ def _ensure_legacy_lot(cls, item, warehouse_id, moved_by, updated_stock=None, mo db.session.add(legacy) @classmethod - def _apply_lot_changes(cls, movement, item, updated_stock=None, unit_cost=None, lot_type=None, consume_from_lot_id=None): + def _apply_lot_changes( + cls, movement, item, updated_stock=None, unit_cost=None, lot_type=None, consume_from_lot_id=None + ): """ Apply the movement to StockLots and create StockLotAllocations. @@ -213,7 +216,12 @@ def _apply_lot_changes(cls, movement, item, updated_stock=None, unit_cost=None, return # Handle inbound transfer: replicate allocations from the outbound paired movement if available. - if qty > 0 and movement.movement_type == "transfer" and movement.reference_type == "transfer" and movement.reference_id: + if ( + qty > 0 + and movement.movement_type == "transfer" + and movement.reference_type == "transfer" + and movement.reference_id + ): out_move = ( cls.query.filter( cls.movement_type == "transfer", @@ -268,7 +276,9 @@ def _apply_lot_changes(cls, movement, item, updated_stock=None, unit_cost=None, # If allocations didn't cover full qty (older data), fall back to a normal inbound lot. if remaining > 0: - inbound_cost = Decimal(str(unit_cost)) if unit_cost is not None else (item.default_cost or Decimal("0")) + inbound_cost = ( + Decimal(str(unit_cost)) if unit_cost is not None else (item.default_cost or Decimal("0")) + ) inbound_type = lot_type or "normal" lot = StockLot( stock_item_id=movement.stock_item_id, @@ -344,15 +354,16 @@ def _apply_lot_changes(cls, movement, item, updated_stock=None, unit_cost=None, preferred_lot = StockLot.query.get(int(consume_from_lot_id)) lots = ( - lots_q.filter(StockLot.quantity_on_hand != 0) - .order_by(StockLot.created_at.asc(), StockLot.id.asc()) - .all() + lots_q.filter(StockLot.quantity_on_hand != 0).order_by(StockLot.created_at.asc(), StockLot.id.asc()).all() ) if preferred_lot: # Put preferred lot first if it matches scope and has non-zero quantity. lots = [l for l in lots if l.id != preferred_lot.id] - if preferred_lot.stock_item_id == movement.stock_item_id and preferred_lot.warehouse_id == movement.warehouse_id: + if ( + preferred_lot.stock_item_id == movement.stock_item_id + and preferred_lot.warehouse_id == movement.warehouse_id + ): if Decimal(str(preferred_lot.quantity_on_hand or 0)) != 0: lots = [preferred_lot] + lots @@ -436,8 +447,8 @@ def record_devaluation( tuple: (StockMovement instance, StockLot instance) """ from .stock_item import StockItem - from .warehouse_stock import WarehouseStock from .stock_lot import StockLot, StockLotAllocation + from .warehouse_stock import WarehouseStock qty = Decimal(str(quantity)) if qty <= 0: diff --git a/app/models/stock_reservation.py b/app/models/stock_reservation.py index 8a051a6f..f76e7396 100644 --- a/app/models/stock_reservation.py +++ b/app/models/stock_reservation.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from decimal import Decimal + from app import db diff --git a/app/models/supplier.py b/app/models/supplier.py index 41ff336c..58e7eb32 100644 --- a/app/models/supplier.py +++ b/app/models/supplier.py @@ -1,6 +1,7 @@ """Supplier model for inventory management""" from datetime import datetime + from app import db diff --git a/app/models/supplier_stock_item.py b/app/models/supplier_stock_item.py index 6b07e3e2..eb3bbc6f 100644 --- a/app/models/supplier_stock_item.py +++ b/app/models/supplier_stock_item.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/task.py b/app/models/task.py index 534a2f0b..761fc684 100644 --- a/app/models/task.py +++ b/app/models/task.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone @@ -81,9 +82,9 @@ def is_overdue(self): def total_hours(self): """Calculate total hours spent on this task""" # Use cached value if available (set by TaskService.list_tasks for performance) - if hasattr(self, '_cached_total_hours'): + if hasattr(self, "_cached_total_hours"): return self._cached_total_hours - + try: from .time_entry import TimeEntry @@ -130,9 +131,9 @@ def progress_percentage(self): def status_display(self): """Get human-readable status from kanban columns""" # Use cached value if available (set by TaskService.list_tasks for performance) - if hasattr(self, '_cached_status_display'): + if hasattr(self, "_cached_status_display"): return self._cached_status_display - + from .kanban_column import KanbanColumn column = KanbanColumn.get_column_by_key(self.status) diff --git a/app/models/tax_rule.py b/app/models/tax_rule.py index eaf84bfa..ac538595 100644 --- a/app/models/tax_rule.py +++ b/app/models/tax_rule.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/team_chat.py b/app/models/team_chat.py index 1d0d90e7..5e87a37d 100644 --- a/app/models/team_chat.py +++ b/app/models/team_chat.py @@ -3,9 +3,11 @@ """ from datetime import datetime -from app import db + from sqlalchemy import Index +from app import db + class ChatChannel(db.Model): """Chat channel/room for team communication""" diff --git a/app/models/time_entry.py b/app/models/time_entry.py index 1fce9d6d..bf349775 100644 --- a/app/models/time_entry.py +++ b/app/models/time_entry.py @@ -1,7 +1,8 @@ from datetime import datetime, timedelta, timezone + from app import db from app.config import Config -from app.utils.timezone import utc_to_local, local_to_utc +from app.utils.timezone import local_to_utc, utc_to_local def local_now(): @@ -201,6 +202,7 @@ def _naive_dt(self, dt): if dt.tzinfo is None: return dt from app.utils.timezone import get_timezone_obj + tz = get_timezone_obj() return dt.astimezone(tz).replace(tzinfo=None) diff --git a/app/models/time_entry_approval.py b/app/models/time_entry_approval.py index e88bdf49..d63fae18 100644 --- a/app/models/time_entry_approval.py +++ b/app/models/time_entry_approval.py @@ -2,10 +2,12 @@ Time Entry Approval models for manager approval workflow """ +import enum from datetime import datetime -from app import db + from sqlalchemy import Enum as SQLEnum -import enum + +from app import db class ApprovalStatus(enum.Enum): diff --git a/app/models/time_entry_template.py b/app/models/time_entry_template.py index 994cbbce..e704d59f 100644 --- a/app/models/time_entry_template.py +++ b/app/models/time_entry_template.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/time_off.py b/app/models/time_off.py index 4ee9f200..fc0228ef 100644 --- a/app/models/time_off.py +++ b/app/models/time_off.py @@ -1,7 +1,8 @@ -๏ปฟfrom datetime import datetime -import enum +๏ปฟimport enum +from datetime import datetime -from sqlalchemy import Enum as SQLEnum, Index +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import Index from app import db @@ -33,12 +34,12 @@ def to_dict(self): "name": self.name, "code": self.code, "is_paid": self.is_paid, - "annual_allowance_hours": float(self.annual_allowance_hours) - if self.annual_allowance_hours is not None - else None, - "accrual_hours_per_month": float(self.accrual_hours_per_month) - if self.accrual_hours_per_month is not None - else None, + "annual_allowance_hours": ( + float(self.annual_allowance_hours) if self.annual_allowance_hours is not None else None + ), + "accrual_hours_per_month": ( + float(self.accrual_hours_per_month) if self.accrual_hours_per_month is not None else None + ), "enabled": self.enabled, } diff --git a/app/models/timesheet_period.py b/app/models/timesheet_period.py index 6bc66620..d8b87414 100644 --- a/app/models/timesheet_period.py +++ b/app/models/timesheet_period.py @@ -1,7 +1,8 @@ -๏ปฟfrom datetime import datetime, date -import enum +๏ปฟimport enum +from datetime import date, datetime -from sqlalchemy import Enum as SQLEnum, UniqueConstraint, Index +from sqlalchemy import Enum as SQLEnum +from sqlalchemy import Index, UniqueConstraint from app import db diff --git a/app/models/user.py b/app/models/user.py index 1328c32b..ffcba7ab 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -1,8 +1,10 @@ +import os from datetime import datetime + from flask_login import UserMixin -from werkzeug.security import generate_password_hash, check_password_hash +from werkzeug.security import check_password_hash, generate_password_hash + from app import db -import os class User(UserMixin, db.Model): @@ -35,8 +37,12 @@ class User(UserMixin, db.Model): notification_task_assigned = db.Column(db.Boolean, default=True, nullable=False) # Notify when assigned to task 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 - notification_remind_to_log = db.Column(db.Boolean, default=False, nullable=False) # Remind to log time at end of day - reminder_to_log_time = db.Column(db.String(5), nullable=True) # Time of day "HH:MM" (24h) for reminder, e.g. "17:00" + notification_remind_to_log = db.Column( + db.Boolean, default=False, nullable=False + ) # Remind to log time at end of day + reminder_to_log_time = db.Column( + db.String(5), nullable=True + ) # Time of day "HH:MM" (24h) for reminder, e.g. "17:00" timezone = db.Column(db.String(50), nullable=True) # User-specific timezone override date_format = db.Column(db.String(20), default=None, nullable=True) # None = use system default time_format = db.Column(db.String(10), default=None, nullable=True) # None = use system default @@ -57,9 +63,7 @@ class User(UserMixin, db.Model): overtime_calculation_mode = db.Column( db.String(10), default="daily", nullable=False ) # 'daily' | 'weekly': overtime by daily cap vs weekly cap - standard_hours_per_week = db.Column( - db.Float, nullable=True - ) # Used when overtime_calculation_mode is 'weekly' + standard_hours_per_week = db.Column(db.Float, nullable=True) # Used when overtime_calculation_mode is 'weekly' # Client portal settings client_portal_enabled = db.Column(db.Boolean, default=False, nullable=False) # Enable/disable client portal access @@ -252,6 +256,7 @@ def is_online(self): if not self.last_login: return False from datetime import timedelta + threshold = datetime.utcnow() - timedelta(minutes=15) return self.last_login >= threshold @@ -259,11 +264,12 @@ def get_status(self): """Get user status: 'online', 'offline', or 'away'""" if not self.last_login: return "offline" - + from datetime import timedelta + now = datetime.utcnow() time_since_login = now - self.last_login - + # Online if active within last 15 minutes if time_since_login <= timedelta(minutes=15): return "online" @@ -280,11 +286,12 @@ def to_dict(self, total_hours_override=None): total_hours_override: optional precomputed total hours (avoids N+1 when serializing many users). """ from app.utils.timezone import ( + get_app_timezone, get_resolved_date_format_key, get_resolved_time_format_key, get_user_timezone_name, - get_app_timezone, ) + try: resolved_date = get_resolved_date_format_key(self) resolved_time = get_resolved_time_format_key(self) @@ -377,7 +384,7 @@ def has_permission(self, permission_name): # Auto-assign role from legacy role field if user has no roles assigned if not self.roles and self.role: self._auto_assign_role_from_legacy() - + # Super admin users have all permissions if self.role == "admin" and not self.roles: # Legacy admin users without roles have all permissions @@ -387,23 +394,25 @@ def has_permission(self, permission_name): for role in self.roles: if role.has_permission(permission_name): return True - + # Fallback: Check legacy role field if no roles assigned # This handles cases where role assignment failed or user is in transition if not self.roles and self.role: from app.models import Role + legacy_role = Role.query.filter_by(name=self.role).first() if legacy_role and legacy_role.has_permission(permission_name): return True - + return False - + def _auto_assign_role_from_legacy(self): """Auto-assign role from legacy role field if user has no roles assigned""" if self.roles or not self.role: return - + from app.models import Role + role_obj = Role.query.filter_by(name=self.role).first() if role_obj: self.roles.append(role_obj) @@ -490,10 +499,10 @@ def get_client_portal_data(self): if not self.is_client_portal_user: return None - from .project import Project + from .client import Client from .invoice import Invoice + from .project import Project from .time_entry import TimeEntry - from .client import Client # Get client - try relationship first, then query by ID if needed client = self.client diff --git a/app/models/user_client.py b/app/models/user_client.py index fe0f6096..0aa9d920 100644 --- a/app/models/user_client.py +++ b/app/models/user_client.py @@ -1,6 +1,7 @@ """User-Client association for subcontractor scope (restrict user to assigned clients).""" from datetime import datetime + from app import db diff --git a/app/models/user_favorite_project.py b/app/models/user_favorite_project.py index 8182d6af..bc9caee1 100644 --- a/app/models/user_favorite_project.py +++ b/app/models/user_favorite_project.py @@ -1,4 +1,5 @@ from datetime import datetime + from app import db diff --git a/app/models/warehouse.py b/app/models/warehouse.py index 2d77ee6e..715c6f9f 100644 --- a/app/models/warehouse.py +++ b/app/models/warehouse.py @@ -1,6 +1,7 @@ """Warehouse model for inventory management""" from datetime import datetime + from app import db diff --git a/app/models/warehouse_stock.py b/app/models/warehouse_stock.py index 05b0b5ec..e02850a8 100644 --- a/app/models/warehouse_stock.py +++ b/app/models/warehouse_stock.py @@ -2,6 +2,7 @@ from datetime import datetime from decimal import Decimal + from app import db diff --git a/app/models/webhook.py b/app/models/webhook.py index 621b78dd..36c53976 100644 --- a/app/models/webhook.py +++ b/app/models/webhook.py @@ -1,10 +1,11 @@ """Webhook models for enabling integrations""" -import secrets import hashlib import hmac import json +import secrets from datetime import datetime + from app import db from app.utils.timezone import now_in_app_timezone @@ -57,12 +58,9 @@ class Webhook(db.Model): 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"), - ) + # Indexes (user_id and is_active already have index=True on their columns; + # only created_at needs an explicit index here) + __table_args__ = (db.Index("ix_webhooks_created_at", "created_at"),) def __repr__(self): return f"" @@ -209,14 +207,9 @@ class WebhookDelivery(db.Model): 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"), - ) + # Indexes (webhook_id, event_type, status, next_retry_at already have index=True; + # only started_at needs an explicit index here) + __table_args__ = (db.Index("ix_webhook_deliveries_started_at", "started_at"),) def __repr__(self): return f"" diff --git a/app/models/weekly_time_goal.py b/app/models/weekly_time_goal.py index 6d50b64f..161c46bc 100644 --- a/app/models/weekly_time_goal.py +++ b/app/models/weekly_time_goal.py @@ -1,7 +1,9 @@ from datetime import datetime, timedelta -from app import db + from sqlalchemy import func +from app import db + def local_now(): """Get current time in local timezone""" @@ -25,7 +27,9 @@ class WeeklyTimeGoal(db.Model): 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 (or Friday if exclude_weekends is True) - exclude_weekends = db.Column(db.Boolean, default=False, nullable=False) # If True, only count weekdays (5-day work week) + exclude_weekends = db.Column( + db.Boolean, default=False, nullable=False + ) # If True, only count weekdays (5-day work week) 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) @@ -90,12 +94,12 @@ def actual_hours(self): func.date(TimeEntry.start_time) >= self.week_start_date, func.date(TimeEntry.start_time) <= self.week_end_date, ).all() - + # If exclude_weekends is True, filter out Saturday (5) and Sunday (6) # Python weekday: Monday=0, Tuesday=1, ..., Sunday=6 if self.exclude_weekends: entries = [e for e in entries if e.start_time.date().weekday() < 5] - + total_seconds = sum(entry.duration_seconds for entry in entries) return round(total_seconds / 3600, 2) @@ -130,7 +134,7 @@ def days_remaining(self): today = local_now().date() if today > self.week_end_date: return 0 - + if self.exclude_weekends: # Count only weekdays (Monday-Friday) days = 0 @@ -156,7 +160,9 @@ def average_hours_per_day(self): def week_label(self): """Get a human-readable label for the week""" if self.exclude_weekends: - return f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')} (Weekdays only)" + return ( + f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')} (Weekdays only)" + ) return f"{self.week_start_date.strftime('%b %d')} - {self.week_end_date.strftime('%b %d, %Y')}" def update_status(self): diff --git a/app/models/workflow.py b/app/models/workflow.py index 89ab1db0..190278e0 100644 --- a/app/models/workflow.py +++ b/app/models/workflow.py @@ -3,9 +3,11 @@ """ from datetime import datetime -from app import db + from sqlalchemy import JSON +from app import db + class WorkflowRule(db.Model): """Workflow rule model for automation""" diff --git a/app/repositories/__init__.py b/app/repositories/__init__.py index 0670bdd1..075850db 100644 --- a/app/repositories/__init__.py +++ b/app/repositories/__init__.py @@ -4,16 +4,16 @@ making it easier to test and maintain. """ -from .time_entry_repository import TimeEntryRepository -from .project_repository import ProjectRepository -from .invoice_repository import InvoiceRepository -from .user_repository import UserRepository from .client_repository import ClientRepository -from .task_repository import TaskRepository +from .comment_repository import CommentRepository from .expense_repository import ExpenseRepository +from .invoice_repository import InvoiceRepository from .payment_repository import PaymentRepository -from .comment_repository import CommentRepository +from .project_repository import ProjectRepository from .recurring_invoice_repository import RecurringInvoiceRepository +from .task_repository import TaskRepository +from .time_entry_repository import TimeEntryRepository +from .user_repository import UserRepository __all__ = [ "TimeEntryRepository", diff --git a/app/repositories/base_repository.py b/app/repositories/base_repository.py index df59a732..df48222f 100644 --- a/app/repositories/base_repository.py +++ b/app/repositories/base_repository.py @@ -13,8 +13,10 @@ def get_active_projects(self): return self.model.query.filter_by(status='active').all() """ -from typing import TypeVar, Generic, List, Optional, Dict, Any +from typing import Any, Dict, Generic, List, Optional, TypeVar + from sqlalchemy.orm import Query + from app import db ModelType = TypeVar("ModelType") diff --git a/app/repositories/client_repository.py b/app/repositories/client_repository.py index 4726e5a5..556658cb 100644 --- a/app/repositories/client_repository.py +++ b/app/repositories/client_repository.py @@ -3,7 +3,9 @@ """ from typing import List, Optional + from sqlalchemy.orm import joinedload + from app import db from app.models import Client from app.repositories.base_repository import BaseRepository diff --git a/app/repositories/comment_repository.py b/app/repositories/comment_repository.py index 84b3a8d9..e008b08e 100644 --- a/app/repositories/comment_repository.py +++ b/app/repositories/comment_repository.py @@ -3,7 +3,9 @@ """ from typing import List, Optional + from sqlalchemy.orm import joinedload + from app import db from app.models import Comment from app.repositories.base_repository import BaseRepository diff --git a/app/repositories/expense_repository.py b/app/repositories/expense_repository.py index acc9a824..c03274de 100644 --- a/app/repositories/expense_repository.py +++ b/app/repositories/expense_repository.py @@ -2,9 +2,11 @@ Repository for expense data access operations. """ +from datetime import date, datetime from typing import List, Optional -from datetime import datetime, date + from sqlalchemy.orm import joinedload + from app import db from app.models import Expense from app.repositories.base_repository import BaseRepository @@ -33,9 +35,7 @@ def get_by_project( query = query.filter(Expense.expense_date <= end_date) if include_relations: - query = query.options( - joinedload(Expense.project), joinedload(Expense.user) - ) + query = query.options(joinedload(Expense.project), joinedload(Expense.user)) return query.order_by(Expense.expense_date.desc()).all() diff --git a/app/repositories/invoice_repository.py b/app/repositories/invoice_repository.py index 75a98567..c14004ba 100644 --- a/app/repositories/invoice_repository.py +++ b/app/repositories/invoice_repository.py @@ -2,13 +2,15 @@ Repository for invoice data access operations. """ +from datetime import date, datetime from typing import List, Optional -from datetime import datetime, date + from sqlalchemy.orm import joinedload + from app import db -from app.models import Invoice, Project, Client -from app.repositories.base_repository import BaseRepository from app.constants import InvoiceStatus, PaymentStatus +from app.models import Client, Invoice, Project +from app.repositories.base_repository import BaseRepository class InvoiceRepository(BaseRepository[Invoice]): @@ -68,6 +70,7 @@ def get_with_relations(self, invoice_id: int) -> Optional[Invoice]: def generate_invoice_number(self) -> str: """Generate a unique invoice number""" from datetime import datetime + from app.models import Settings # Get settings for invoice prefix and start number diff --git a/app/repositories/payment_repository.py b/app/repositories/payment_repository.py index 9e1cd4b8..dbd899e1 100644 --- a/app/repositories/payment_repository.py +++ b/app/repositories/payment_repository.py @@ -2,13 +2,15 @@ Repository for payment data access operations. """ -from typing import List, Optional from datetime import date from decimal import Decimal -from sqlalchemy.orm import joinedload +from typing import List, Optional + from sqlalchemy import func +from sqlalchemy.orm import joinedload + from app import db -from app.models import Payment, Invoice +from app.models import Invoice, Payment from app.repositories.base_repository import BaseRepository diff --git a/app/repositories/project_repository.py b/app/repositories/project_repository.py index 8c0fa75d..6ba2060b 100644 --- a/app/repositories/project_repository.py +++ b/app/repositories/project_repository.py @@ -3,11 +3,13 @@ """ from typing import List, Optional + from sqlalchemy.orm import joinedload + from app import db -from app.models import Project, Client -from app.repositories.base_repository import BaseRepository from app.constants import ProjectStatus +from app.models import Client, Project +from app.repositories.base_repository import BaseRepository class ProjectRepository(BaseRepository[Project]): diff --git a/app/repositories/task_repository.py b/app/repositories/task_repository.py index d3623b22..f4e43452 100644 --- a/app/repositories/task_repository.py +++ b/app/repositories/task_repository.py @@ -3,11 +3,13 @@ """ from typing import List, Optional + from sqlalchemy.orm import joinedload + from app import db +from app.constants import TaskStatus from app.models import Task from app.repositories.base_repository import BaseRepository -from app.constants import TaskStatus class TaskRepository(BaseRepository[Task]): diff --git a/app/repositories/time_entry_repository.py b/app/repositories/time_entry_repository.py index a76e81ee..b6ce29ea 100644 --- a/app/repositories/time_entry_repository.py +++ b/app/repositories/time_entry_repository.py @@ -2,14 +2,16 @@ Repository for time entry data access operations. """ -from typing import List, Optional from datetime import datetime +from typing import List, Optional + from sqlalchemy import and_, or_ from sqlalchemy.orm import joinedload + from app import db -from app.models import TimeEntry, User, Project, Task -from app.repositories.base_repository import BaseRepository from app.constants import TimeEntrySource, TimeEntryStatus +from app.models import Project, Task, TimeEntry, User +from app.repositories.base_repository import BaseRepository class TimeEntryRepository(BaseRepository[TimeEntry]): diff --git a/app/repositories/user_repository.py b/app/repositories/user_repository.py index 25f48b40..782ba903 100644 --- a/app/repositories/user_repository.py +++ b/app/repositories/user_repository.py @@ -3,10 +3,11 @@ """ from typing import List, Optional + from app import db +from app.constants import UserRole from app.models import User from app.repositories.base_repository import BaseRepository -from app.constants import UserRole class UserRepository(BaseRepository[User]): diff --git a/app/routes/activity_feed.py b/app/routes/activity_feed.py index 248f5c9b..0dfb663f 100644 --- a/app/routes/activity_feed.py +++ b/app/routes/activity_feed.py @@ -2,13 +2,15 @@ Activity Feed routes """ -from flask import Blueprint, render_template, request, jsonify -from flask_login import login_required, current_user -from app import db -from app.models import Activity +from datetime import datetime, timedelta + +from flask import Blueprint, jsonify, render_template, request from flask_babel import gettext as _ +from flask_login import current_user, login_required from sqlalchemy import and_ -from datetime import datetime, timedelta + +from app import db +from app.models import Activity from app.utils.module_helpers import module_enabled activity_feed_bp = Blueprint("activity_feed", __name__) diff --git a/app/routes/admin.py b/app/routes/admin.py index e1abadcf..fab40119 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -1,36 +1,49 @@ +import os +import shutil +import threading +import time +import uuid +from datetime import datetime + from flask import ( Blueprint, - render_template, - request, - redirect, - url_for, - flash, current_app, - send_from_directory, - send_file, + flash, jsonify, + redirect, + render_template, render_template_string, + request, + send_file, + send_from_directory, + url_for, ) from flask_babel import gettext as _ -from flask_login import login_required, current_user -import app as app_module -from app import db, limiter -from app.models import User, Project, TimeEntry, Settings, Invoice, Quote, QuoteItem, Role, UserClient, DonationInteraction -from datetime import datetime +from flask_login import current_user, login_required from sqlalchemy import text from sqlalchemy.exc import ProgrammingError -import os from werkzeug.utils import secure_filename -import uuid + +import app as app_module +from app import db, limiter +from app.models import ( + DonationInteraction, + Invoice, + Project, + Quote, + QuoteItem, + Role, + Settings, + TimeEntry, + User, + UserClient, +) +from app.utils.backup import create_backup, get_backup_root_dir, restore_backup from app.utils.db import safe_commit -from app.utils.backup import create_backup, restore_backup, get_backup_root_dir from app.utils.installation import get_installation_config -from app.utils.telemetry import get_telemetry_fingerprint, is_telemetry_enabled from app.utils.permissions import admin_or_permission_required +from app.utils.telemetry import get_telemetry_fingerprint, is_telemetry_enabled from app.utils.timezone import get_available_timezones -import threading -import time -import shutil admin_bp = Blueprint("admin", __name__) @@ -45,31 +58,32 @@ def _convert_json_template_to_html_css(template_json, page_size="A4", invoice=None, quote=None, settings=None): """ Convert JSON template to HTML/CSS for preview purposes with full element type support. - + Args: template_json: Dictionary containing template definition page_size: Page size for CSS @page rule invoice: Optional invoice object for table data rendering quote: Optional quote object for table data rendering settings: Optional settings object for company information - + Returns: tuple: (html_string, css_string) """ - from app.utils.pdf_template_schema import get_page_dimensions_points import html as html_escape import json as json_module - + + from app.utils.pdf_template_schema import get_page_dimensions_points + # Get page dimensions dims = get_page_dimensions_points(page_size) width_pt = dims["width"] height_pt = dims["height"] - + # Convert points to pixels at 96 DPI for browser (72 DPI * 96/72 = 1.333) # But for accuracy, use 1pt = 1.333px conversion for browser width_px = int(width_pt * 96 / 72) height_px = int(height_pt * 96 / 72) - + # Font mapping: ReportLab fonts to web fonts font_map = { "Helvetica": "Arial, Helvetica, sans-serif", @@ -85,14 +99,14 @@ def _convert_json_template_to_html_css(template_json, page_size="A4", invoice=No "Courier-Oblique": "Courier New, Courier, monospace", "Courier-BoldOblique": "Courier New, Courier, monospace", } - + # Get page margins from template page_config = template_json.get("page", {}) margin_top = page_config.get("margin", {}).get("top", 20) margin_bottom = page_config.get("margin", {}).get("bottom", 20) margin_left = page_config.get("margin", {}).get("left", 20) margin_right = page_config.get("margin", {}).get("right", 20) - + # Build CSS with @page rule and comprehensive styles css = f"""@page {{ size: {page_size}; @@ -186,13 +200,13 @@ def _convert_json_template_to_html_css(template_json, page_size="A4", invoice=No background-color: #f0f0f0; }} """ - + # Helper function to map ReportLab fonts to web fonts def get_font_family(font_name): if not font_name: return "Arial, Helvetica, sans-serif" return font_map.get(font_name, font_map.get(font_name.split("-")[0], "Arial, Helvetica, sans-serif")) - + # Helper function to get font weight from font name def get_font_weight(font_name): if not font_name: @@ -200,7 +214,7 @@ def get_font_weight(font_name): if "Bold" in font_name: return "bold" return "normal" - + # Helper function to get font style from font name def get_font_style(font_name): if not font_name: @@ -208,7 +222,7 @@ def get_font_style(font_name): if "Oblique" in font_name or "Italic" in font_name: return "italic" return "normal" - + # Helper function to convert color (supports hex, rgb, named colors) def format_color(color): if not color: @@ -220,16 +234,17 @@ def format_color(color): if isinstance(color, (list, tuple)) and len(color) >= 3: return f"#{int(color[0]):02x}{int(color[1]):02x}{int(color[2]):02x}" return str(color) - + # Helper function to render text with Jinja2-like template variables def render_text_template(text, data_obj, settings_obj=None): """Render text with template variables using actual data""" if not text: return text - + # Simple template variable replacement for preview # Replace {{ variable }} patterns with actual values import re + def replace_var(match): var_path = match.group(1).strip() try: @@ -255,12 +270,12 @@ def replace_var(match): return match.group(0) # Return original if no data object except Exception: return match.group(0) # Return original on error - - return re.sub(r'\{\{\s*([^}]+)\s*\}\}', replace_var, text) - + + return re.sub(r"\{\{\s*([^}]+)\s*\}\}", replace_var, text) + # Build HTML from elements html_parts = ['
'] if invoice else ['
'] - + elements = template_json.get("elements", []) for idx, element in enumerate(elements): elem_type = element.get("type", "") @@ -269,36 +284,36 @@ def replace_var(match): style = element.get("style", {}) opacity = style.get("opacity", 1.0) rotation = element.get("rotation", 0) - + # Convert points to pixels at 96 DPI for browser x_px = int(x * 96 / 72) y_px = int(y * 96 / 72) - + # Base style string base_style_parts = [ f"left: {x_px}px", f"top: {y_px}px", f"opacity: {opacity}", ] - + if rotation: base_style_parts.append(f"transform: rotate({rotation}deg)") base_style_parts.append("transform-origin: top left") - + style_str_base = "; ".join(base_style_parts) - + if elem_type == "text": text = element.get("text", "") width = element.get("width", 400) height = element.get("height", None) width_px_elem = int(width * 96 / 72) - + font_name = style.get("font", "Helvetica") font_size = style.get("size", 10) color = format_color(style.get("color", "#000000")) align = style.get("align", "left") valign = style.get("valign", "top") - + # Build complete style string style_parts = [style_str_base] style_parts.append(f"width: {width_px_elem}px") @@ -311,20 +326,20 @@ def replace_var(match): style_parts.append(f"color: {color}") style_parts.append(f"text-align: {align}") style_parts.append(f"vertical-align: {valign}") - + style_str = "; ".join(style_parts) + ";" - + # Render text with actual data if available data_obj = invoice if invoice else quote rendered_text = render_text_template(text, data_obj, settings) if (data_obj or settings) else text - + # Escape HTML but preserve any remaining template syntax text_escaped = html_escape.escape(rendered_text) # Restore template syntax if any remains (shouldn't after rendering, but just in case) text_escaped = text_escaped.replace("<{{", "{{").replace("}}>", "}}") - + html_parts.append(f'
{text_escaped}
') - + elif elem_type == "image": width = element.get("width", 100) height = element.get("height", 100) @@ -332,7 +347,7 @@ def replace_var(match): height_px_elem = int(height * 96 / 72) source = element.get("source", "") is_decorative = element.get("decorative", False) - + # Handle base64 data URLs or file paths img_src = "" if source.startswith("data:"): @@ -341,6 +356,7 @@ def replace_var(match): # Template image - convert to base64 for PDF generation try: from app.utils.template_filters import get_image_base64 + # Extract filename from URL filename = source.split("/uploads/template_images/")[-1] # Build file path relative to app root (as get_image_base64 expects) @@ -360,18 +376,22 @@ def replace_var(match): else: # Placeholder for decorative images without source img_src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23ddd' width='100' height='100'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3EImage%3C/text%3E%3C/svg%3E" - + style_parts = [style_str_base] style_parts.append(f"width: {width_px_elem}px") style_parts.append(f"height: {height_px_elem}px") style_str = "; ".join(style_parts) + ";" - + if img_src and not img_src.startswith("data:image/svg+xml"): - html_parts.append(f'Decorative image') + html_parts.append( + f'Decorative image' + ) else: # Show placeholder for decorative images without source - html_parts.append(f'
Decorative Image
') - + html_parts.append( + f'
Decorative Image
' + ) + elif elem_type == "rectangle": width = element.get("width", 100) height = element.get("height", 100) @@ -380,7 +400,7 @@ def replace_var(match): fill = format_color(style.get("fill", "#ffffff")) stroke = format_color(style.get("stroke", "#000000")) stroke_width = style.get("strokeWidth", 1) - + style_parts = [style_str_base] style_parts.append(f"width: {width_px_elem}px") style_parts.append(f"height: {height_px_elem}px") @@ -388,16 +408,16 @@ def replace_var(match): if stroke_width > 0: style_parts.append(f"border: {stroke_width}px solid {stroke}") style_str = "; ".join(style_parts) + ";" - + html_parts.append(f'
') - + elif elem_type == "circle": radius = element.get("radius", 50) radius_px = int(radius * 96 / 72) fill = format_color(style.get("fill", "#ffffff")) stroke = format_color(style.get("stroke", "#000000")) stroke_width = style.get("strokeWidth", 1) - + style_parts = [style_str_base] style_parts.append(f"width: {radius_px * 2}px") style_parts.append(f"height: {radius_px * 2}px") @@ -405,9 +425,9 @@ def replace_var(match): if stroke_width > 0: style_parts.append(f"border: {stroke_width}px solid {stroke}") style_str = "; ".join(style_parts) + ";" - + html_parts.append(f'
') - + elif elem_type == "line": width = element.get("width", 100) height = element.get("height", 0) @@ -416,33 +436,33 @@ def replace_var(match): stroke_width = height if height > 0 else style.get("strokeWidth", 1) stroke_width_px = max(1, int(stroke_width * 96 / 72)) stroke = format_color(style.get("stroke", "#000000")) - + style_parts = [style_str_base] style_parts.append(f"width: {width_px_elem}px") style_parts.append(f"height: {stroke_width_px}px") style_parts.append(f"background-color: {stroke}") style_str = "; ".join(style_parts) + ";" - + html_parts.append(f'
') - + elif elem_type == "table": width = element.get("width", 500) width_px_elem = int(width * 96 / 72) columns = element.get("columns", []) row_template = element.get("row_template", {}) - + # Get table style properties table_style = element.get("style", {}) border_color = format_color(table_style.get("borderColor", "#000000")) header_bg = format_color(table_style.get("headerBackground", "#f8f9fa")) - + style_parts = [style_str_base] style_parts.append(f"width: {width_px_elem}px") style_parts.append(f"border: 1px solid {border_color}") style_str = "; ".join(style_parts) + ";" - + table_html = f'' - + # Build header row for col in columns: header = col.get("header", "") @@ -450,8 +470,8 @@ def replace_var(match): col_width = col.get("width", None) width_attr = f' width="{int(col_width * 96 / 72)}px"' if col_width else "" table_html += f'' - table_html += '' - + table_html += "" + # Resolve table data from element's data source (e.g. invoice.all_line_items or invoice.items) data_obj = invoice if invoice else quote items = [] @@ -486,15 +506,15 @@ def replace_var(match): items = list(data_obj.items) if data_obj.items else [] except Exception: items = [] - + # If no items available, create sample row from template if not items and row_template: items = [row_template] # Use template as sample data - + # Render table rows with actual data if items: for item in items[:10]: # Limit to 10 rows for preview - table_html += '' + table_html += "" for col in columns: field = col.get("field", "") align = col.get("align", "left") @@ -509,26 +529,28 @@ def replace_var(match): value = "" except Exception: value = "" - + value_escaped = html_escape.escape(str(value)) table_html += f'' - table_html += '' + table_html += "" else: # No data available, show template placeholders - table_html += '' + table_html += "" for col in columns: field = col.get("field", "") align = col.get("align", "left") placeholder = f"{{{{ {field} }}}}" - table_html += f'' - table_html += '' - - table_html += '
{html_escape.escape(header)}
{value_escaped}
{html_escape.escape(placeholder)}
' + table_html += ( + f'{html_escape.escape(placeholder)}' + ) + table_html += "" + + table_html += "" html_parts.append(table_html) - - html_parts.append('
') - html = '\n'.join(html_parts) - + + html_parts.append("
") + html = "\n".join(html_parts) + return html, css @@ -572,9 +594,11 @@ def get_upload_folder(): @admin_or_permission_required("access_admin") def admin_dashboard(): """Admin dashboard""" - from app.config import Config from datetime import datetime, timedelta - from sqlalchemy import func, case + + from sqlalchemy import case, func + + from app.config import Config # Get system statistics total_users = User.query.count() @@ -610,6 +634,7 @@ def admin_dashboard(): # Chart data for last 30 days (cached 10 min to reduce DB load) from app.utils.cache import get_cache + _cache = get_cache() chart_data = _cache.get("admin:dashboard:chart") if chart_data is None: @@ -649,16 +674,11 @@ def _norm_date(v): ) user_activity_by_date = {_norm_date(d.day): d.cnt for d in user_activity_rows} user_activity_data = [ - {"date": d.strftime("%Y-%m-%d"), "count": user_activity_by_date.get(d, 0)} - for d in all_dates + {"date": d.strftime("%Y-%m-%d"), "count": user_activity_by_date.get(d, 0)} for d in all_dates ] project_status_data = {} - status_counts = ( - db.session.query(Project.status, func.count(Project.id)) - .group_by(Project.status) - .all() - ) + status_counts = db.session.query(Project.status, func.count(Project.id)).group_by(Project.status).all() for status, count in status_counts: project_status_data[status or "none"] = count @@ -681,8 +701,7 @@ def _norm_date(v): if day is not None: time_hours_by_date[day] = round((row.total_seconds or 0) / 3600, 2) time_entries_daily = [ - {"date": d.strftime("%Y-%m-%d"), "hours": time_hours_by_date.get(d, 0)} - for d in all_dates + {"date": d.strftime("%Y-%m-%d"), "hours": time_hours_by_date.get(d, 0)} for d in all_dates ] chart_data = { "user_activity": user_activity_data, @@ -839,18 +858,36 @@ def edit_user(user_id): if not username: flash(_("Username is required"), "error") - return render_template("admin/user_form.html", user=user, clients=clients, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", + user=user, + clients=clients, + all_roles=all_roles, + assigned_client_ids=assigned_client_ids, + ) # 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, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", + user=user, + clients=clients, + all_roles=all_roles, + assigned_client_ids=assigned_client_ids, + ) # 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, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", + user=user, + clients=clients, + all_roles=all_roles, + assigned_client_ids=assigned_client_ids, + ) # Get the Role object from the database role_obj = Role.query.filter_by(name=role_name).first() @@ -859,7 +896,13 @@ def edit_user(user_id): role_obj = Role.query.filter_by(name="user").first() if not role_obj: flash(_("Default 'user' role not found. Please run 'flask seed_permissions_cmd' first."), "error") - return render_template("admin/user_form.html", user=user, clients=clients, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", + user=user, + clients=clients, + all_roles=all_roles, + assigned_client_ids=assigned_client_ids, + ) # Handle password reset if provided new_password = request.form.get("new_password", "").strip() @@ -870,11 +913,23 @@ def edit_user(user_id): # Validate password if len(new_password) < 8: flash(_("Password must be at least 8 characters long."), "error") - return render_template("admin/user_form.html", user=user, clients=clients, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", + user=user, + clients=clients, + all_roles=all_roles, + assigned_client_ids=assigned_client_ids, + ) if new_password != password_confirm: flash(_("Passwords do not match."), "error") - return render_template("admin/user_form.html", user=user, clients=clients, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", + user=user, + clients=clients, + all_roles=all_roles, + assigned_client_ids=assigned_client_ids, + ) # Set the new password user.set_password(new_password) @@ -888,7 +943,7 @@ def edit_user(user_id): user.username = username # Update legacy role field for backward compatibility user.role = role_name - + # Update roles in the new system # If user doesn't have the selected role, assign it as the primary role # Keep other roles if they exist (multi-role support) @@ -905,7 +960,7 @@ def edit_user(user_id): if user.roles[0] != role_obj: user.roles.remove(role_obj) user.roles.insert(0, role_obj) - + user.is_active = is_active user.client_portal_enabled = client_portal_enabled user.client_id = int(client_id) if client_id else None @@ -921,7 +976,13 @@ def edit_user(user_id): 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, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", + user=user, + clients=clients, + all_roles=all_roles, + assigned_client_ids=assigned_client_ids, + ) if new_password: flash(_('Password reset successfully for user "%(username)s"', username=username), "success") @@ -929,7 +990,9 @@ def edit_user(user_id): 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, all_roles=all_roles, assigned_client_ids=assigned_client_ids) + return render_template( + "admin/user_form.html", user=user, clients=clients, all_roles=all_roles, assigned_client_ids=assigned_client_ids + ) @admin_bp.route("/admin/users//delete", methods=["POST"]) @@ -1048,25 +1111,25 @@ def clear_cache(): @admin_or_permission_required("manage_settings") def manage_modules(): """Manage module visibility - enable/disable modules system-wide""" - from app.utils.module_registry import ModuleRegistry, ModuleCategory from app.models.client import Client - + from app.utils.module_registry import ModuleCategory, ModuleRegistry + # Initialize registry ModuleRegistry.initialize_defaults() - + # Get settings to access disabled_module_ids settings_obj = Settings.get_settings() # For locked client selection UI clients = Client.query.filter_by(status="active").order_by(Client.name).all() - + # Module visibility: non-CORE modules for admin toggles modules_by_category = {} for cat in ModuleCategory: mods = [m for m in ModuleRegistry.get_by_category(cat) if m.category != ModuleCategory.CORE] if mods: modules_by_category[cat] = mods - + if request.method == "POST": # Locked client: allow admin to lock the instance to a single client locked_client_id_raw = (request.form.get("locked_client_id") or "").strip() @@ -1104,7 +1167,7 @@ def manage_modules(): for m in mods: if ("module_enabled_" + m.id) not in request.form: disabled.append(m.id) - + # Validate module dependencies before saving validation_errors = [] for module_id in disabled: @@ -1112,12 +1175,17 @@ def manage_modules(): if not can_disable and affected: module = ModuleRegistry.get(module_id) module_name = module.name if module else module_id - affected_names = [ModuleRegistry.get(aid).name if ModuleRegistry.get(aid) else aid for aid in affected] + affected_names = [ + ModuleRegistry.get(aid).name if ModuleRegistry.get(aid) else aid for aid in affected + ] validation_errors.append( - _("Cannot disable '%(module)s' because the following modules depend on it: %(dependents)s", - module=module_name, dependents=", ".join(affected_names)) + _( + "Cannot disable '%(module)s' because the following modules depend on it: %(dependents)s", + module=module_name, + dependents=", ".join(affected_names), + ) ) - + if validation_errors: for error in validation_errors: flash(error, "error") @@ -1128,15 +1196,17 @@ def manage_modules(): settings=settings_obj, clients=clients, ) - + settings_obj.disabled_module_ids = disabled - + # Ensure settings object is in the session if settings_obj not in db.session: db.session.add(settings_obj) - + if not safe_commit("admin_update_module_visibility"): - flash(_("Could not update module visibility due to a database error. Please check server logs."), "error") + flash( + _("Could not update module visibility due to a database error. Please check server logs."), "error" + ) return render_template( "admin/modules.html", modules_by_category=modules_by_category, @@ -1144,10 +1214,10 @@ def manage_modules(): settings=settings_obj, clients=clients, ) - + flash(_("Module visibility updated successfully"), "success") return redirect(url_for("admin.manage_modules")) - + return render_template( "admin/modules.html", modules_by_category=modules_by_category, @@ -1163,6 +1233,7 @@ def manage_modules(): def settings(): """Manage system settings""" import os # Ensure os is available in function scope + settings_obj = Settings.get_settings() installation_config = get_installation_config() timezones = get_available_timezones() @@ -1237,7 +1308,20 @@ def settings(): # #region agent log try: import json - log_data = {"location": "admin.py:952", "message": "Saving invoice prefix and start number", "data": {"invoice_prefix_form": str(invoice_prefix_form), "invoice_start_number_form": str(invoice_start_number_form), "settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID"}, "timestamp": int(datetime.utcnow().timestamp() * 1000), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "F"} + + log_data = { + "location": "admin.py:952", + "message": "Saving invoice prefix and start number", + "data": { + "invoice_prefix_form": str(invoice_prefix_form), + "invoice_start_number_form": str(invoice_start_number_form), + "settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID", + }, + "timestamp": int(datetime.utcnow().timestamp() * 1000), + "sessionId": "debug-session", + "runId": "run1", + "hypothesisId": "F", + } log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".cursor", "debug.log") with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_data) + "\n") @@ -1352,7 +1436,20 @@ def settings(): # #region agent log try: import json - log_data = {"location": "admin.py:1027", "message": "After commit - settings values", "data": {"invoice_prefix": str(settings_obj.invoice_prefix), "invoice_start_number": int(settings_obj.invoice_start_number), "settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID"}, "timestamp": int(datetime.utcnow().timestamp() * 1000), "sessionId": "debug-session", "runId": "run1", "hypothesisId": "G"} + + log_data = { + "location": "admin.py:1027", + "message": "After commit - settings values", + "data": { + "invoice_prefix": str(settings_obj.invoice_prefix), + "invoice_start_number": int(settings_obj.invoice_start_number), + "settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID", + }, + "timestamp": int(datetime.utcnow().timestamp() * 1000), + "sessionId": "debug-session", + "runId": "run1", + "hypothesisId": "G", + } log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".cursor", "debug.log") with open(log_path, "a", encoding="utf-8") as f: f.write(json.dumps(log_data) + "\n") @@ -1388,6 +1485,7 @@ def settings(): def admin_verify_donate_hide_code(): """Verify code (Ed25519 or HMAC) and set system-wide donate_ui_hidden=True.""" import hmac + from app.utils.donate_hide_code import compute_donate_hide_code, verify_ed25519_signature settings_obj = Settings.get_settings() @@ -1429,43 +1527,62 @@ def pdf_layout(): # Get page size from query parameter or form, default to A4 page_size_raw = request.args.get("size", request.form.get("page_size", "A4")) - current_app.logger.info(f"[PDF_TEMPLATE] Action: template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Action: template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}" + ) # Ensure valid page size valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size_raw not in valid_sizes: - current_app.logger.warning(f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}") + current_app.logger.warning( + f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}" + ) page_size = "A4" else: page_size = page_size_raw - current_app.logger.info(f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}" + ) # Get or create template for this page size (ensures JSON exists) - current_app.logger.info(f"[PDF_TEMPLATE] Retrieving template from database - PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Retrieving template from database - PageSize: '{page_size}', User: {current_user.username}" + ) template = InvoicePDFTemplate.get_template(page_size) - current_app.logger.info(f"[PDF_TEMPLATE] Template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}" + ) if request.method == "POST": - current_app.logger.info(f"[PDF_TEMPLATE] Action: template_save, PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Action: template_save, PageSize: '{page_size}', User: {current_user.username}" + ) 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", "") template_json = request.form.get("template_json", "") # ReportLab template JSON date_format = request.form.get("date_format", "%d.%m.%Y") # Date format for this template - current_app.logger.info(f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}" + ) # Validate and ensure template_json is present import json + template_json_dict = None if template_json and template_json.strip(): try: - current_app.logger.info(f"[PDF_TEMPLATE] Parsing template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Parsing template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}" + ) template_json_dict = json.loads(template_json) # Ensure page size matches in JSON json_page_size = template_json_dict.get("page", {}).get("size") - current_app.logger.info(f"[PDF_TEMPLATE] Template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'") + current_app.logger.info( + f"[PDF_TEMPLATE] Template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'" + ) if "page" in template_json_dict and "size" in template_json_dict["page"]: template_json_dict["page"]["size"] = page_size else: @@ -1473,15 +1590,16 @@ def pdf_layout(): if "page" not in template_json_dict: template_json_dict["page"] = {} template_json_dict["page"]["size"] = page_size - + # CRITICAL: Ensure page dimensions (width/height) match the page size # This fixes layout issues when templates are customized from app.utils.pdf_template_schema import get_page_dimensions_mm + template_page_config = template_json_dict.get("page", {}) expected_dims = get_page_dimensions_mm(page_size) current_width = template_page_config.get("width") current_height = template_page_config.get("height") - + if current_width != expected_dims["width"] or current_height != expected_dims["height"]: current_app.logger.info( f"[PDF_TEMPLATE] Updating template page dimensions - PageSize: '{page_size}', " @@ -1490,31 +1608,42 @@ def pdf_layout(): template_page_config["width"] = expected_dims["width"] template_page_config["height"] = expected_dims["height"] template_json_dict["page"] = template_page_config - + template_json = json.dumps(template_json_dict) element_count = len(template_json_dict.get("elements", [])) - current_app.logger.info(f"[PDF_TEMPLATE] Template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}" + ) except json.JSONDecodeError as e: - current_app.logger.error(f"[PDF_TEMPLATE] Invalid template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] Invalid template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}" + ) flash(_("Invalid template JSON format. Please try again."), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) else: # If no template_json provided, generate default - current_app.logger.warning(f"[PDF_TEMPLATE] No template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.warning( + f"[PDF_TEMPLATE] No template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}" + ) from app.utils.pdf_template_schema import get_default_template + template_json_dict = get_default_template(page_size) template_json = json.dumps(template_json_dict) element_count = len(template_json_dict.get("elements", [])) - current_app.logger.info(f"[PDF_TEMPLATE] Generated default template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Generated default template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}" + ) # Normalize @page size in CSS to match the selected page size before saving # This ensures that saved templates always have the correct page size if css_template: from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css - - current_app.logger.info(f"[PDF_TEMPLATE] Normalizing CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}") + + current_app.logger.info( + f"[PDF_TEMPLATE] Normalizing CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}" + ) css_template = update_page_size_in_css(css_template, page_size) - + # Validate after normalization is_valid, found_sizes = validate_page_size_in_css(css_template, page_size) if not is_valid: @@ -1522,31 +1651,44 @@ def pdf_layout(): f"[PDF_TEMPLATE] CSS @page size normalization issue - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}" ) else: - current_app.logger.info(f"[PDF_TEMPLATE] CSS @page size normalized successfully - PageSize: '{page_size}'") + current_app.logger.info( + f"[PDF_TEMPLATE] CSS @page size normalized successfully - PageSize: '{page_size}'" + ) # Validate template_json before saving if not template_json or not template_json.strip(): - current_app.logger.error(f"[PDF_TEMPLATE] ERROR: template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] ERROR: template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) flash(_("Error: Template JSON is empty. Please try saving again."), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) - + # Validate that template_json is valid JSON try: import json + template_json_dict_validate = json.loads(template_json) if not isinstance(template_json_dict_validate, dict) or "page" not in template_json_dict_validate: - current_app.logger.error(f"[PDF_TEMPLATE] ERROR: template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] ERROR: template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) flash(_("Error: Template JSON is invalid. Please try saving again."), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) element_count = len(template_json_dict_validate.get("elements", [])) - current_app.logger.info(f"[PDF_TEMPLATE] Template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}" + ) except json.JSONDecodeError as e: - current_app.logger.error(f"[PDF_TEMPLATE] ERROR: template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] ERROR: template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}" + ) flash(_("Error: Template JSON is not valid JSON. Please try saving again."), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) # Update template (save both legacy HTML/CSS and new JSON format) - current_app.logger.info(f"[PDF_TEMPLATE] Updating template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Updating template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) template.template_html = html_template template.template_css = css_template template.design_json = design_json @@ -1556,32 +1698,48 @@ def pdf_layout(): # For backwards compatibility, also update Settings when saving A4 (default) if page_size == "A4": - current_app.logger.info(f"[PDF_TEMPLATE] Also updating Settings for A4 default - User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Also updating Settings for A4 default - User: {current_user.username}" + ) 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 - current_app.logger.info(f"[PDF_TEMPLATE] Committing template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Committing template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) if not safe_commit("admin_update_pdf_layout"): from flask_babel import gettext as _ - current_app.logger.error(f"[PDF_TEMPLATE] Database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + + current_app.logger.error( + f"[PDF_TEMPLATE] Database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) flash(_("Could not update PDF layout due to a database error."), "error") else: from flask_babel import gettext as _ + # Verify that template_json was actually saved db.session.refresh(template) if template.template_json and template.template_json.strip() and template.template_json == template_json: - current_app.logger.info(f"[PDF_TEMPLATE] Template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}" + ) flash(_("PDF layout updated successfully"), "success") else: - current_app.logger.error(f"[PDF_TEMPLATE] WARNING: Template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}") - flash(_("PDF layout saved but template JSON verification failed. Please check the template."), "warning") + current_app.logger.error( + f"[PDF_TEMPLATE] WARNING: Template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}" + ) + flash( + _("PDF layout saved but template JSON verification failed. Please check the template."), "warning" + ) return redirect(url_for("admin.pdf_layout", size=page_size)) # Get all templates for dropdown all_templates = InvoicePDFTemplate.get_all_templates() - current_app.logger.info(f"[PDF_TEMPLATE] Loaded all templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Loaded all templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}" + ) # DON'T call ensure_template_json() here - it may overwrite saved templates # Template should already have JSON if it was saved properly @@ -1589,19 +1747,26 @@ def pdf_layout(): if template.template_json: try: import json + template_json_check = json.loads(template.template_json) element_count = len(template_json_check.get("elements", [])) json_page_size = template_json_check.get("page", {}).get("size", "unknown") - current_app.logger.info(f"[PDF_TEMPLATE] Template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}") + current_app.logger.info( + f"[PDF_TEMPLATE] Template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}" + ) except Exception as e: - current_app.logger.warning(f"[PDF_TEMPLATE] Template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}") + current_app.logger.warning( + f"[PDF_TEMPLATE] Template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}" + ) # 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 "" template_json = template.template_json or "" - current_app.logger.info(f"[PDF_TEMPLATE] Template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}") + current_app.logger.info( + f"[PDF_TEMPLATE] Template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}" + ) # Fallback to legacy Settings if template is empty if not initial_html and not initial_css: @@ -1640,6 +1805,7 @@ def pdf_layout(): # This ensures the editor always shows the correct page size if initial_css: from app.utils.pdf_generator import update_page_size_in_css + initial_css = update_page_size_in_css(initial_css, page_size) return render_template( @@ -1651,7 +1817,7 @@ def pdf_layout(): template_json=template_json, page_size=page_size, all_templates=all_templates, - date_format=getattr(template, 'date_format', None) or '%d.%m.%Y', + date_format=getattr(template, "date_format", None) or "%d.%m.%Y", ) @@ -1661,38 +1827,39 @@ def pdf_layout(): @admin_or_permission_required("manage_settings") def pdf_layout_reset(): """Reset PDF layout to defaults (clear custom templates and regenerate default JSON).""" + import json + from app.models import InvoicePDFTemplate from app.utils.pdf_template_schema import get_default_template - import json - + # Get page size from query parameter or form, default to 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"] if page_size not in valid_sizes: page_size = "A4" - + # Get or create template for this page size template = InvoicePDFTemplate.get_template(page_size) - + # Clear custom templates template.template_html = "" template.template_css = "" template.design_json = "" - + # Regenerate default JSON template default_json = get_default_template(page_size) template.template_json = json.dumps(default_json) template.updated_at = datetime.utcnow() - + # Also clear legacy Settings for A4 if page_size == "A4": 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") else: @@ -1710,43 +1877,62 @@ def quote_pdf_layout(): # Get page size from query parameter or form, default to A4 page_size_raw = request.args.get("size", request.form.get("page_size", "A4")) - current_app.logger.info(f"[PDF_TEMPLATE] Action: quote_template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Action: quote_template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}" + ) # Ensure valid page size valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size_raw not in valid_sizes: - current_app.logger.warning(f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}") + current_app.logger.warning( + f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}" + ) page_size = "A4" else: page_size = page_size_raw - current_app.logger.info(f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}" + ) # Get or create template for this page size (ensures JSON exists) - current_app.logger.info(f"[PDF_TEMPLATE] Retrieving quote template from database - PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Retrieving quote template from database - PageSize: '{page_size}', User: {current_user.username}" + ) template = QuotePDFTemplate.get_template(page_size) - current_app.logger.info(f"[PDF_TEMPLATE] Quote template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}" + ) if request.method == "POST": - current_app.logger.info(f"[PDF_TEMPLATE] Action: quote_template_save, PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Action: quote_template_save, PageSize: '{page_size}', User: {current_user.username}" + ) 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", "") template_json = request.form.get("template_json", "") # ReportLab template JSON date_format = request.form.get("date_format", "%d.%m.%Y") # Date format for this template - current_app.logger.info(f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}" + ) # Validate and ensure template_json is present import json + template_json_dict = None if template_json and template_json.strip(): try: - current_app.logger.info(f"[PDF_TEMPLATE] Parsing quote template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Parsing quote template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}" + ) template_json_dict = json.loads(template_json) # Ensure page size matches in JSON json_page_size = template_json_dict.get("page", {}).get("size") - current_app.logger.info(f"[PDF_TEMPLATE] Quote template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'" + ) if "page" in template_json_dict and "size" in template_json_dict["page"]: template_json_dict["page"]["size"] = page_size else: @@ -1754,15 +1940,16 @@ def quote_pdf_layout(): if "page" not in template_json_dict: template_json_dict["page"] = {} template_json_dict["page"]["size"] = page_size - + # CRITICAL: Ensure page dimensions (width/height) match the page size # This fixes layout issues when templates are customized from app.utils.pdf_template_schema import get_page_dimensions_mm + template_page_config = template_json_dict.get("page", {}) expected_dims = get_page_dimensions_mm(page_size) current_width = template_page_config.get("width") current_height = template_page_config.get("height") - + if current_width != expected_dims["width"] or current_height != expected_dims["height"]: current_app.logger.info( f"[PDF_TEMPLATE] Updating quote template page dimensions - PageSize: '{page_size}', " @@ -1771,29 +1958,40 @@ def quote_pdf_layout(): template_page_config["width"] = expected_dims["width"] template_page_config["height"] = expected_dims["height"] template_json_dict["page"] = template_page_config - + template_json = json.dumps(template_json_dict) element_count = len(template_json_dict.get("elements", [])) - current_app.logger.info(f"[PDF_TEMPLATE] Quote template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}" + ) except json.JSONDecodeError as e: - current_app.logger.error(f"[PDF_TEMPLATE] Invalid quote template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] Invalid quote template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}" + ) flash(_("Invalid template JSON format. Please try again."), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) else: # If no template_json provided, generate default - current_app.logger.warning(f"[PDF_TEMPLATE] No quote template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.warning( + f"[PDF_TEMPLATE] No quote template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}" + ) from app.utils.pdf_template_schema import get_default_template + template_json_dict = get_default_template(page_size) template_json = json.dumps(template_json_dict) element_count = len(template_json_dict.get("elements", [])) - current_app.logger.info(f"[PDF_TEMPLATE] Generated default quote template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Generated default quote template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}" + ) # Normalize @page size in CSS to match the selected page size before saving # This ensures that saved templates always have the correct page size if css_template: from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css - current_app.logger.info(f"[PDF_TEMPLATE] Normalizing quote CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}") + current_app.logger.info( + f"[PDF_TEMPLATE] Normalizing quote CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}" + ) css_template = update_page_size_in_css(css_template, page_size) # Validate after normalization @@ -1803,56 +2001,81 @@ def quote_pdf_layout(): f"[PDF_TEMPLATE] Quote CSS @page size normalization issue - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}" ) else: - current_app.logger.info(f"[PDF_TEMPLATE] Quote CSS @page size normalized successfully - PageSize: '{page_size}'") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote CSS @page size normalized successfully - PageSize: '{page_size}'" + ) # Update template (save both legacy HTML/CSS and new JSON format) - current_app.logger.info(f"[PDF_TEMPLATE] Updating quote template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Updating quote template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) template.template_html = html_template template.template_css = css_template template.design_json = design_json # Validate template_json before saving if not template_json or not template_json.strip(): - current_app.logger.error(f"[PDF_TEMPLATE] ERROR: Quote template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] ERROR: Quote template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) flash(_("Error: Template JSON is empty. Please try saving again."), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) - + # Validate that template_json is valid JSON try: import json + template_json_dict_validate = json.loads(template_json) if not isinstance(template_json_dict_validate, dict) or "page" not in template_json_dict_validate: - current_app.logger.error(f"[PDF_TEMPLATE] ERROR: Quote template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] ERROR: Quote template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) flash(_("Error: Template JSON is invalid. Please try saving again."), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) element_count = len(template_json_dict_validate.get("elements", [])) - current_app.logger.info(f"[PDF_TEMPLATE] Quote template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}" + ) except json.JSONDecodeError as e: - current_app.logger.error(f"[PDF_TEMPLATE] ERROR: Quote template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] ERROR: Quote template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}" + ) flash(_("Error: Template JSON is not valid JSON. Please try saving again."), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) - + template.template_json = template_json # ReportLab template JSON (always present now) template.date_format = date_format # Date format for this template template.updated_at = datetime.utcnow() - current_app.logger.info(f"[PDF_TEMPLATE] Committing quote template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Committing quote template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) if not safe_commit("admin_update_quote_pdf_layout"): - current_app.logger.error(f"[PDF_TEMPLATE] Quote template database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_TEMPLATE] Quote template database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}" + ) flash(_("Could not update PDF layout due to a database error."), "error") else: # Verify that template_json was actually saved db.session.refresh(template) if template.template_json and template.template_json.strip() and template.template_json == template_json: - current_app.logger.info(f"[PDF_TEMPLATE] Quote template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}" + ) flash(_("PDF layout updated successfully"), "success") else: - current_app.logger.error(f"[PDF_TEMPLATE] WARNING: Quote template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}") - flash(_("PDF layout saved but template JSON verification failed. Please check the template."), "warning") + current_app.logger.error( + f"[PDF_TEMPLATE] WARNING: Quote template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}" + ) + flash( + _("PDF layout saved but template JSON verification failed. Please check the template."), "warning" + ) return redirect(url_for("admin.quote_pdf_layout", size=page_size)) # Get all templates for dropdown all_templates = QuotePDFTemplate.get_all_templates() - current_app.logger.info(f"[PDF_TEMPLATE] Loaded all quote templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}") + current_app.logger.info( + f"[PDF_TEMPLATE] Loaded all quote templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}" + ) # DON'T call ensure_template_json() here - it may overwrite saved templates # Template should already have JSON if it was saved properly @@ -1860,19 +2083,26 @@ def quote_pdf_layout(): if template.template_json: try: import json + template_json_check = json.loads(template.template_json) element_count = len(template_json_check.get("elements", [])) json_page_size = template_json_check.get("page", {}).get("size", "unknown") - current_app.logger.info(f"[PDF_TEMPLATE] Quote template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}" + ) except Exception as e: - current_app.logger.warning(f"[PDF_TEMPLATE] Quote template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}") + current_app.logger.warning( + f"[PDF_TEMPLATE] Quote template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}" + ) # Provide initial defaults initial_html = template.template_html or "" initial_css = template.template_css or "" design_json = template.design_json or "" template_json = template.template_json or "" - current_app.logger.info(f"[PDF_TEMPLATE] Quote template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}") + current_app.logger.info( + f"[PDF_TEMPLATE] Quote template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}" + ) # Load default template if empty try: @@ -1897,6 +2127,7 @@ def quote_pdf_layout(): # This ensures the editor always shows the correct page size if initial_css: from app.utils.pdf_generator import update_page_size_in_css + initial_css = update_page_size_in_css(initial_css, page_size) return render_template( @@ -1908,7 +2139,7 @@ def quote_pdf_layout(): template_json=template_json, page_size=page_size, all_templates=all_templates, - date_format=getattr(template, 'date_format', None) or '%d.%m.%Y', + date_format=getattr(template, "date_format", None) or "%d.%m.%Y", ) @@ -1918,9 +2149,10 @@ def quote_pdf_layout(): @admin_or_permission_required("manage_settings") def quote_pdf_layout_reset(): """Reset quote PDF layout to defaults (clear custom templates and regenerate default JSON).""" + import json + from app.models import QuotePDFTemplate from app.utils.pdf_template_schema import get_default_template - import json # Get page size from query parameter or form, default to A4 page_size = request.args.get("size", request.form.get("page_size", "A4")) @@ -1937,7 +2169,7 @@ def quote_pdf_layout_reset(): template.template_html = "" template.template_css = "" template.design_json = "" - + # Regenerate default JSON template default_json = get_default_template(page_size) template.template_json = json.dumps(default_json) @@ -1955,37 +2187,33 @@ def quote_pdf_layout_reset(): @admin_or_permission_required("manage_settings") def quote_pdf_layout_export_json(page_size): """Export quote PDF template as JSON file.""" - from app.models import QuotePDFTemplate from io import BytesIO - + + from app.models import QuotePDFTemplate + # Validate page size valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: flash(_("Invalid page size"), "error") return redirect(url_for("admin.quote_pdf_layout", size="A4")) - + # Get template template = QuotePDFTemplate.query.filter_by(page_size=page_size).first() if not template: flash(_("Template not found for this page size"), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) - + # Get template JSON template_json = template.template_json or "{}" - + # Create file-like object output = BytesIO() - output.write(template_json.encode('utf-8')) + output.write(template_json.encode("utf-8")) output.seek(0) - + # Return as downloadable file filename = f"quote_pdf_template_{page_size}.json" - return send_file( - output, - mimetype='application/json', - as_attachment=True, - download_name=filename - ) + return send_file(output, mimetype="application/json", as_attachment=True, download_name=filename) @admin_bp.route("/admin/quote-pdf-layout/import-json", methods=["POST"]) @@ -1994,67 +2222,68 @@ def quote_pdf_layout_export_json(page_size): @admin_or_permission_required("manage_settings") def quote_pdf_layout_import_json(): """Import quote PDF template from JSON file.""" + import json + from app.models import QuotePDFTemplate from app.utils.pdf_template_schema import get_page_dimensions_mm - import json - + # Get page size from form or detect from JSON page_size = request.form.get("page_size", "A4") - + # Validate page size valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: page_size = "A4" - + # Check if file was uploaded - if 'json_file' not in request.files: + if "json_file" not in request.files: flash(_("No file uploaded"), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) - - file = request.files['json_file'] - if file.filename == '': + + file = request.files["json_file"] + if file.filename == "": flash(_("No file selected"), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) - + # Read and parse JSON try: - file_content = file.read().decode('utf-8') + file_content = file.read().decode("utf-8") template_json_dict = json.loads(file_content) - + # Validate JSON structure if not isinstance(template_json_dict, dict) or "page" not in template_json_dict: flash(_("Invalid template JSON format. Missing 'page' property."), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) - + # Detect page size from JSON if not provided json_page_size = template_json_dict.get("page", {}).get("size") if json_page_size and json_page_size in valid_sizes: page_size = json_page_size - + # Update page size in JSON template_json_dict["page"]["size"] = page_size - + # Ensure page dimensions match expected_dims = get_page_dimensions_mm(page_size) template_page_config = template_json_dict.get("page", {}) template_page_config["width"] = expected_dims["width"] template_page_config["height"] = expected_dims["height"] template_json_dict["page"] = template_page_config - + # Get or create template template = QuotePDFTemplate.get_template(page_size) - + # Update template JSON template.template_json = json.dumps(template_json_dict) template.updated_at = datetime.utcnow() - + if not safe_commit("admin_import_quote_pdf_layout_json"): flash(_("Could not import template due to a database error."), "error") else: flash(_("Template imported successfully"), "success") - + return redirect(url_for("admin.quote_pdf_layout", size=page_size)) - + except json.JSONDecodeError as e: flash(_("Invalid JSON file: %(error)s", error=str(e)), "error") return redirect(url_for("admin.quote_pdf_layout", size=page_size)) @@ -2069,37 +2298,33 @@ def quote_pdf_layout_import_json(): @admin_or_permission_required("manage_settings") def pdf_layout_export_json(page_size): """Export invoice PDF template as JSON file.""" - from app.models import InvoicePDFTemplate from io import BytesIO - + + from app.models import InvoicePDFTemplate + # Validate page size valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: flash(_("Invalid page size"), "error") return redirect(url_for("admin.pdf_layout", size="A4")) - + # Get template template = InvoicePDFTemplate.query.filter_by(page_size=page_size).first() if not template: flash(_("Template not found for this page size"), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) - + # Get template JSON template_json = template.template_json or "{}" - + # Create file-like object output = BytesIO() - output.write(template_json.encode('utf-8')) + output.write(template_json.encode("utf-8")) output.seek(0) - + # Return as downloadable file filename = f"invoice_pdf_template_{page_size}.json" - return send_file( - output, - mimetype='application/json', - as_attachment=True, - download_name=filename - ) + return send_file(output, mimetype="application/json", as_attachment=True, download_name=filename) @admin_bp.route("/admin/pdf-layout/import-json", methods=["POST"]) @@ -2108,67 +2333,68 @@ def pdf_layout_export_json(page_size): @admin_or_permission_required("manage_settings") def pdf_layout_import_json(): """Import invoice PDF template from JSON file.""" + import json + from app.models import InvoicePDFTemplate from app.utils.pdf_template_schema import get_page_dimensions_mm - import json - + # Get page size from form or detect from JSON page_size = request.form.get("page_size", "A4") - + # Validate page size valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size not in valid_sizes: page_size = "A4" - + # Check if file was uploaded - if 'json_file' not in request.files: + if "json_file" not in request.files: flash(_("No file uploaded"), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) - - file = request.files['json_file'] - if file.filename == '': + + file = request.files["json_file"] + if file.filename == "": flash(_("No file selected"), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) - + # Read and parse JSON try: - file_content = file.read().decode('utf-8') + file_content = file.read().decode("utf-8") template_json_dict = json.loads(file_content) - + # Validate JSON structure if not isinstance(template_json_dict, dict) or "page" not in template_json_dict: flash(_("Invalid template JSON format. Missing 'page' property."), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) - + # Detect page size from JSON if not provided json_page_size = template_json_dict.get("page", {}).get("size") if json_page_size and json_page_size in valid_sizes: page_size = json_page_size - + # Update page size in JSON template_json_dict["page"]["size"] = page_size - + # Ensure page dimensions match expected_dims = get_page_dimensions_mm(page_size) template_page_config = template_json_dict.get("page", {}) template_page_config["width"] = expected_dims["width"] template_page_config["height"] = expected_dims["height"] template_json_dict["page"] = template_page_config - + # Get or create template template = InvoicePDFTemplate.get_template(page_size) - + # Update template JSON template.template_json = json.dumps(template_json_dict) template.updated_at = datetime.utcnow() - + if not safe_commit("admin_import_pdf_layout_json"): flash(_("Could not import template due to a database error."), "error") else: flash(_("Template imported successfully"), "success") - + return redirect(url_for("admin.pdf_layout", size=page_size)) - + except json.JSONDecodeError as e: flash(_("Invalid JSON file: %(error)s", error=str(e)), "error") return redirect(url_for("admin.pdf_layout", size=page_size)) @@ -2272,49 +2498,71 @@ def pdf_layout_preview(): template_json_str = request.form.get("template_json", "") # JSON template from editor page_size_raw = request.form.get("page_size", "A4") # Get page size from form invoice_id = request.form.get("invoice_id", type=int) - - current_app.logger.info(f"[PDF_PREVIEW] Action: invoice_preview_request, PageSize: '{page_size_raw}', HTML length: {len(html)}, CSS length: {len(css)}, TemplateJSON length: {len(template_json_str)}, InvoiceID: {invoice_id}, User: {current_user.username}") - + + current_app.logger.info( + f"[PDF_PREVIEW] Action: invoice_preview_request, PageSize: '{page_size_raw}', HTML length: {len(html)}, CSS length: {len(css)}, TemplateJSON length: {len(template_json_str)}, InvoiceID: {invoice_id}, User: {current_user.username}" + ) + # Validate page size valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"] if page_size_raw not in valid_sizes: - current_app.logger.warning(f"[PDF_PREVIEW] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}") + current_app.logger.warning( + f"[PDF_PREVIEW] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}" + ) page_size = "A4" else: page_size = page_size_raw - - current_app.logger.info(f"[PDF_PREVIEW] Final validated PageSize: '{page_size}', TemplateJSON provided: {bool(template_json_str and template_json_str.strip())}") - + + current_app.logger.info( + f"[PDF_PREVIEW] Final validated PageSize: '{page_size}', TemplateJSON provided: {bool(template_json_str and template_json_str.strip())}" + ) + # CRITICAL: Always load saved template_json from database for preview # This ensures we use the ACTUAL saved template, not what's in the form (which might be empty) from app.models import InvoicePDFTemplate + template_json_parsed = None saved_template = InvoicePDFTemplate.query.filter_by(page_size=page_size).first() if saved_template and saved_template.template_json and saved_template.template_json.strip(): import json + try: - current_app.logger.info(f"[PDF_PREVIEW] Loading saved template JSON from database - PageSize: '{page_size}', TemplateID: {saved_template.id}, JSON length: {len(saved_template.template_json)}") + current_app.logger.info( + f"[PDF_PREVIEW] Loading saved template JSON from database - PageSize: '{page_size}', TemplateID: {saved_template.id}, JSON length: {len(saved_template.template_json)}" + ) template_json_parsed = json.loads(saved_template.template_json) element_count = len(template_json_parsed.get("elements", [])) json_page_size = template_json_parsed.get("page", {}).get("size", "unknown") - current_app.logger.info(f"[PDF_PREVIEW] Saved template JSON loaded - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}") + current_app.logger.info( + f"[PDF_PREVIEW] Saved template JSON loaded - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}" + ) except json.JSONDecodeError as e: - current_app.logger.error(f"[PDF_PREVIEW] Failed to parse saved template JSON - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}", exc_info=True) + current_app.logger.error( + f"[PDF_PREVIEW] Failed to parse saved template JSON - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}", + exc_info=True, + ) template_json_parsed = None - + # If form provided template_json, use it (for live editing preview) if not template_json_parsed and template_json_str and template_json_str.strip(): import json + try: - current_app.logger.info(f"[PDF_PREVIEW] Parsing form-provided JSON template - PageSize: '{page_size}', JSON length: {len(template_json_str)}") + current_app.logger.info( + f"[PDF_PREVIEW] Parsing form-provided JSON template - PageSize: '{page_size}', JSON length: {len(template_json_str)}" + ) template_json_parsed = json.loads(template_json_str) element_count = len(template_json_parsed.get("elements", [])) json_page_size = template_json_parsed.get("page", {}).get("size", "unknown") - current_app.logger.info(f"[PDF_PREVIEW] Form JSON template parsed - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}") + current_app.logger.info( + f"[PDF_PREVIEW] Form JSON template parsed - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}" + ) except json.JSONDecodeError as e: - current_app.logger.warning(f"[PDF_PREVIEW] Invalid form template_json - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}") + current_app.logger.warning( + f"[PDF_PREVIEW] Invalid form template_json - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}" + ) template_json_parsed = None - + invoice = None if invoice_id: invoice = Invoice.query.get(invoice_id) @@ -2493,40 +2741,54 @@ def pdf_layout_preview(): # Use the wrapper instead of the original invoice invoice = invoice_wrapper - + # CRITICAL: Always use template_json for preview - convert to HTML/CSS with actual invoice data if template_json_parsed: try: # Convert JSON template to HTML/CSS with actual invoice data for better table rendering - html, css = _convert_json_template_to_html_css(template_json_parsed, page_size, invoice=invoice, quote=None, settings=settings_obj) - items_count = len(invoice.items) if hasattr(invoice, 'items') and invoice.items else 0 - current_app.logger.info(f"[PDF_PREVIEW] JSON template converted with invoice data - PageSize: '{page_size}', HTML length: {len(html)}, CSS length: {len(css)}, Items count: {items_count}") + html, css = _convert_json_template_to_html_css( + template_json_parsed, page_size, invoice=invoice, quote=None, settings=settings_obj + ) + items_count = len(invoice.items) if hasattr(invoice, "items") and invoice.items else 0 + current_app.logger.info( + f"[PDF_PREVIEW] JSON template converted with invoice data - PageSize: '{page_size}', HTML length: {len(html)}, CSS length: {len(css)}, Items count: {items_count}" + ) except Exception as e: - current_app.logger.error(f"[PDF_PREVIEW] Failed to convert JSON template with invoice data - PageSize: '{page_size}', Error: {str(e)}", exc_info=True) + current_app.logger.error( + f"[PDF_PREVIEW] Failed to convert JSON template with invoice data - PageSize: '{page_size}', Error: {str(e)}", + exc_info=True, + ) # Fall back to empty HTML/CSS html = "
" css = "" else: # No template_json available - this should not happen if template was saved - current_app.logger.error(f"[PDF_PREVIEW] No template JSON available for preview - PageSize: '{page_size}', SavedTemplateExists: {saved_template is not None}, SavedTemplateHasJSON: {saved_template.template_json if saved_template else False}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_PREVIEW] No template JSON available for preview - PageSize: '{page_size}', SavedTemplateExists: {saved_template is not None}, SavedTemplateHasJSON: {saved_template.template_json if saved_template else False}, User: {current_user.username}" + ) html = "

Error: No template found. Please save a template first.

" css = "" # CRITICAL: Load the saved template CSS for this page size and merge with editor CSS # The editor generates minimal CSS, but we need the full template CSS for proper preview - from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css import re - + + from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css + saved_css = None # Initialize saved_css to avoid UnboundLocalError if saved_template: - current_app.logger.info(f"[PDF_PREVIEW] Retrieved saved invoice template - PageSize: '{page_size}', TemplateID: {saved_template.id}, HasCSS: {bool(saved_template.template_css)}") + current_app.logger.info( + f"[PDF_PREVIEW] Retrieved saved invoice template - PageSize: '{page_size}', TemplateID: {saved_template.id}, HasCSS: {bool(saved_template.template_css)}" + ) if saved_template.template_css and saved_template.template_css.strip(): # Use the saved template CSS as base, but normalize it first to ensure correct @page size saved_css = saved_template.template_css # CRITICAL: Normalize the saved template CSS to ensure it has the correct @page size saved_css = update_page_size_in_css(saved_css, page_size) - current_app.logger.info(f"[PDF_PREVIEW] Using saved invoice template CSS - PageSize: '{page_size}', CSS length: {len(saved_css)}, TemplateID: {saved_template.id}") - + current_app.logger.info( + f"[PDF_PREVIEW] Using saved invoice template CSS - PageSize: '{page_size}', CSS length: {len(saved_css)}, TemplateID: {saved_template.id}" + ) + # If editor provided CSS, merge it (editor CSS takes precedence for @page rules) if css and css.strip(): # Extract @page rule from editor CSS if present @@ -2565,11 +2827,11 @@ def pdf_layout_preview(): # Always normalize @page size to ensure it matches the selected page size css_before = css css = update_page_size_in_css(css, page_size) - + # Log if normalization changed anything if css != css_before: current_app.logger.debug(f"PDF Preview - CSS @page size normalized from template/editor to {page_size}") - + # Validate after normalization is_valid, found_sizes = validate_page_size_in_css(css, page_size) if not is_valid: @@ -2584,28 +2846,29 @@ def pdf_layout_preview(): # Try to fix it by replacing any existing @page size # Use a more robust regex that handles quotes and whitespace css = re.sub( - r"size\s*:\s*['\"]?[^;}\n]+['\"]?", - f"size: {page_size}", - css, - flags=re.IGNORECASE | re.MULTILINE + r"size\s*:\s*['\"]?[^;}\n]+['\"]?", f"size: {page_size}", css, flags=re.IGNORECASE | re.MULTILINE ) else: # No CSS provided, add default @page rule css = update_page_size_in_css("", page_size) - + # Final validation and logging is_valid, found_sizes = validate_page_size_in_css(css, page_size) if is_valid: - current_app.logger.info(f"[PDF_PREVIEW] CSS validated successfully - PageSize: '{page_size}', Final CSS length: {len(css)}, Final HTML length: {len(html)}") + current_app.logger.info( + f"[PDF_PREVIEW] CSS validated successfully - PageSize: '{page_size}', Final CSS length: {len(css)}, Final HTML length: {len(html)}" + ) else: - current_app.logger.error(f"[PDF_PREVIEW] CSS validation FAILED - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}") + current_app.logger.error( + f"[PDF_PREVIEW] CSS validation FAILED - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}" + ) # Helper: remove @page rules from HTML inline styles when separate CSS exists # This matches the fix used in PDF exports to avoid conflicts with WeasyPrint 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 @@ -2614,7 +2877,7 @@ def remove_from_style_tag(match): brace_count = 0 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 @@ -2631,22 +2894,23 @@ def remove_from_style_tag(match): 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)}" - + # Match )" 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 - + # Apply @page rule removal fix: if we have separate CSS and HTML with inline styles, # remove @page rules from HTML to ensure the separate CSS @page rule is used html_has_inline_styles = html and ")" 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 - + # Apply @page rule removal fix: if we have separate CSS and HTML with inline styles, # remove @page rules from HTML to ensure the separate CSS @page rule is used html_has_inline_styles = html and "