diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index cda89288..98e917b2 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -31,15 +31,18 @@ flowchart LR |-------|----------|------| | Entry point | `app.py` | Creates Flask app, loads config, registers blueprints via `blueprint_registry`, starts server (and optional SocketIO/scheduler). | | Blueprint registry | `app/blueprint_registry.py` | Single place that imports and registers all route blueprints so `app/__init__.py` stays manageable. | -| Routes | `app/routes/` | HTTP handlers: auth, main (dashboard), projects, timer, reports, admin, api, api_v1, tasks, issues, invoices, clients, etc. | +| Routes | `app/routes/` | HTTP handlers: auth, main (dashboard), projects, timer, reports, admin, api, api_v1 (plus api_v1_* sub-blueprints), tasks, issues, invoices, clients, etc. | | Services | `app/services/` | Business logic; routes call services instead of putting logic in view code. | +| Repositories | `app/repositories/` | Data access layer; services and routes use repositories for queries and eager loading. | | Models | `app/models/` | SQLAlchemy ORM models (users, projects, time entries, tasks, clients, etc.). | +| Schemas | `app/schemas/` | Marshmallow schemas for API request/response validation and serialization. | | Templates | `app/templates/` | Jinja2 HTML templates for server-rendered pages. | -| Utils | `app/utils/` | Helpers: timezone, validation, API responses, auth. | +| Utils | `app/utils/` | Helpers: timezone, validation, API responses, auth, setup_logging, legacy_migrations. | +| Config | `app/config.py` | Application configuration (env-based). | | Desktop | `desktop/` | Electron-style desktop app (esbuild bundle) that talks to the API. | | Mobile | `mobile/` | Flutter mobile app (iOS/Android) using the REST API. | | Docker | `docker/`, root `Dockerfile` | Container build and runtime; optional Nginx, DB init scripts. | -| Tests | `tests/` | Pytest-based test suite. | +| Tests | `tests/` | Pytest-based test suite (test_routes, test_services, test_models, test_utils, test_integration). | ```mermaid flowchart TB @@ -59,13 +62,20 @@ flowchart TB ## Data Flow -- **Web request:** User or browser → Nginx (if used) → Flask → blueprint in `app/routes/` → optional **service** in `app/services/` → **models** and DB → response (HTML or JSON). -- **API request:** Same path; API blueprints (`api`, `api_v1`, `api_v1_time_entries`, etc.) return JSON and use token auth (see [API documentation](docs/api/REST_API.md)). +- **Web request:** User or browser → Nginx (if used) → Flask → blueprint in `app/routes/` → optional **service** in `app/services/` → **repositories** / **models** and DB → response (HTML or JSON). +- **API request:** Same path; API blueprints return JSON and use token auth. Request → route → service (or repository) → model/DB → `api_responses` helpers → JSON. - **Real-time:** Flask-SocketIO is used for live timer updates; clients connect over WebSocket and receive events from the server. - **Background:** APScheduler runs periodic tasks (e.g. scheduled reports, weekly summaries, remind-to-log end-of-day emails, reminders, cleanup) inside the app process. Report exports include time-entries PDF and summary-report PDF ([app/utils/summary_report_pdf.py](app/utils/summary_report_pdf.py)). API endpoints are versioned under `/api/v1/`. Authentication is session-based for the web UI and API-token (Bearer or `X-API-Key`) for the API. +## API Structure + +- **Base URL:** `/api/v1/` +- **Auth:** API token in header `Authorization: Bearer ` or `X-API-Key: `. Tokens are created in Admin → Api-tokens and have scopes (e.g. `read:projects`, `write:time_entries`). +- **Sub-blueprints (all under `/api/v1/`):** `api_v1` (info, health, auth/login), `api_v1_time_entries`, `api_v1_projects`, `api_v1_tasks`, `api_v1_clients`, `api_v1_invoices`, `api_v1_expenses`, `api_v1_payments`, `api_v1_mileage`, `api_v1_deals`, `api_v1_leads`, `api_v1_contacts`, plus remaining routes in `api_v1` (time-entry-approvals, per-diems, budget-alerts, calendar, kanban, saved-filters, etc.). +- **Full reference:** [REST API](docs/api/REST_API.md). + ## Backend vs Frontend - **Backend:** Flask (Python), Jinja2, SQLAlchemy, Flask-Migrate, Flask-Login, Authlib (OIDC), Flask-SocketIO, APScheduler. Configuration via environment variables (see `env.example`). @@ -76,6 +86,8 @@ API endpoints are versioned under `/api/v1/`. Authentication is session-based fo ## Design Decisions - **Service layer:** Business logic lives in `app/services/` so routes stay thin and logic is reusable and testable. See [Service Layer and Base CRUD](docs/development/SERVICE_LAYER_AND_BASE_CRUD.md) and the [Architecture Migration Guide](docs/implementation-notes/ARCHITECTURE_MIGRATION_GUIDE.md). +- **API v1 split:** Core resources (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) are in separate sub-blueprints (`api_v1_*.py`) under `/api/v1/` for maintainability; the main `api_v1` module keeps info, health, auth, and remaining endpoints. +- **Bootstrap:** Logging is configured in `app/utils/setup_logging.py`; legacy migration helpers (task management, issues tables) are in `app/utils/legacy_migrations.py`. `app/__init__.py` creates the app and wires extensions. - **Blueprint registry:** All blueprints are registered from `app/blueprint_registry.py` to keep registration in one place and simplify adding new modules. - **Database:** **PostgreSQL** is recommended for production; **SQLite** is supported for development and testing (e.g. `docker-compose.local-test.yml`). - **API auth:** The REST API uses API tokens (created in Admin → Api-tokens) with scopes; no session cookies for API access. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3313248c..439e658c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps. + ### Fixed - **Dashboard cache (Issue #549)** — Removed dashboard caching that caused "Instance not bound to a Session" and "Database Error" on second visit. Cached template data contained ORM objects (active_timer, recent_entries, top_projects, templates, etc.) that become detached when served in a different request. - **Task description field (Issue #535)** — When creating or editing a task, the description field could appear missing or broken if the Toast UI Editor (loaded from CDN) failed to load (e.g. reverse proxy, CSP, Firefox, or offline). A fallback now shows a plain textarea so users can always enter a description; Markdown is still supported when the rich editor loads. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 357584de..4f968b06 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -90,6 +90,13 @@ For more detail, see [ARCHITECTURE.md](ARCHITECTURE.md) and [Project Structure]( - Follow the [Contributing guidelines](docs/development/CONTRIBUTING.md): PEP 8, Black (line length 88), type hints and docstrings where appropriate. - Use blueprints for routes; keep business logic in [services](docs/development/SERVICE_LAYER_AND_BASE_CRUD.md). +## Development Workflow + +1. Create a branch for your change. +2. Run tests locally: `pytest` (or `pytest --cov=app` for coverage). +3. Lint/format: follow [Contributing](docs/development/CONTRIBUTING.md) (e.g. Black, flake8). +4. For user-facing changes, add an entry under **Unreleased** in [CHANGELOG.md](CHANGELOG.md). + ## Running Tests ```bash @@ -101,10 +108,19 @@ pytest --cov=app # Single file pytest tests/test_timer.py + +# Single test class or test +pytest tests/test_routes/test_api_v1_projects_refactored.py -v ``` See [Contributing – Testing](docs/development/CONTRIBUTING.md#testing) for more options and conventions. +## Build Steps + +- **Web app:** No separate frontend build required; Tailwind and static assets are served as-is (or built via your pipeline if you use one). Run the app with `flask run` or `python app.py`. +- **Docker image:** `docker build -t timetracker .` from repo root. See [Docker Compose Setup](docs/admin/configuration/DOCKER_COMPOSE_SETUP.md). +- **Mobile/Desktop:** See [BUILD.md](BUILD.md) and [docs/mobile-desktop-apps/README.md](docs/mobile-desktop-apps/README.md) for Flutter and Electron build steps. + ## Contributing 1. Read [CONTRIBUTING.md](CONTRIBUTING.md). diff --git a/app/__init__.py b/app/__init__.py index c26c4070..36bc62d0 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -658,7 +658,8 @@ def create_app(config=None): app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(seconds=int(os.getenv("PERMANENT_SESSION_LIFETIME", 86400))) # Setup logging (including JSON logging) - setup_logging(app) + 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"): @@ -1267,9 +1268,8 @@ def create_app(config=None): db.create_all() # Check and migrate Task Management tables if needed + from app.utils.legacy_migrations import migrate_task_management_tables, migrate_issues_table migrate_task_management_tables() - - # Check and migrate Issues table if needed migrate_issues_table() # Create default admin user or demo user if it doesn't exist @@ -1316,154 +1316,6 @@ def create_app(config=None): return app -def setup_logging(app): - """Setup application logging including JSON logging""" - log_level = os.getenv("LOG_LEVEL", "INFO") - # Default to a file in the project logs directory if not provided - default_log_path = os.path.abspath( - os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "timetracker.log") - ) - log_file = os.getenv("LOG_FILE", default_log_path) - - # JSON log file path - json_log_path = os.path.abspath(os.path.join(os.path.dirname(os.path.dirname(__file__)), "logs", "app.jsonl")) - - # Prepare handlers - handlers = [logging.StreamHandler()] - - # Add file handler (default or specified) - try: - # Ensure log directory exists - log_dir = os.path.dirname(log_file) - if log_dir and not os.path.exists(log_dir): - os.makedirs(log_dir, exist_ok=True) - - # Create rotating file handler (10MB max, keep 5 backups) - from logging.handlers import RotatingFileHandler - - file_handler = RotatingFileHandler( - log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" - ) - handlers.append(file_handler) - except (PermissionError, OSError) as e: - print(f"Warning: Could not create log file '{log_file}': {e}") - print("Logging to console only") - # Don't add file handler, just use console logging - - # Configure Flask app logger directly (works well under gunicorn) - for handler in handlers: - handler.setLevel(getattr(logging, log_level.upper())) - handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]")) - - # Clear existing handlers to avoid duplicate logs - app.logger.handlers.clear() - app.logger.propagate = False - app.logger.setLevel(getattr(logging, log_level.upper())) - for handler in handlers: - app.logger.addHandler(handler) - - # Also configure root logger so modules using logging.getLogger() are captured - root_logger = logging.getLogger() - root_logger.setLevel(getattr(logging, log_level.upper())) - # Avoid duplicating handlers if already attached - root_logger.handlers = [] - for handler in handlers: - root_logger.addHandler(handler) - - # Setup JSON logging for structured events - try: - json_log_dir = os.path.dirname(json_log_path) - if json_log_dir and not os.path.exists(json_log_dir): - os.makedirs(json_log_dir, exist_ok=True) - - from logging.handlers import RotatingFileHandler as _RotatingFileHandler - - json_handler = _RotatingFileHandler( - json_log_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" - ) - json_formatter = jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s") - json_handler.setFormatter(json_formatter) - json_handler.setLevel(logging.INFO) - - # Add JSON handler to the timetracker logger - json_logger.handlers.clear() - json_logger.addHandler(json_handler) - json_logger.propagate = False - - app.logger.info(f"JSON logging initialized: {json_log_path}") - except (PermissionError, OSError) as e: - app.logger.warning(f"Could not initialize JSON logging: {e}") - - # Suppress noisy logs in production - if not app.debug: - logging.getLogger("werkzeug").setLevel(logging.ERROR) - - -def migrate_task_management_tables(): - """Check and migrate Task Management tables if they don't exist""" - try: - from sqlalchemy import inspect, text - - # Check if tasks table exists - inspector = inspect(db.engine) - existing_tables = inspector.get_table_names() - - if "tasks" not in existing_tables: - print("Task Management: Creating tasks table...") - # Create the tasks table - db.create_all() - print("✓ Tasks table created successfully") - else: - print("Task Management: Tasks table already exists") - - # Check if task_id column exists in time_entries table - if "time_entries" in existing_tables: - time_entries_columns = [col["name"] for col in inspector.get_columns("time_entries")] - if "task_id" not in time_entries_columns: - print("Task Management: Adding task_id column to time_entries table...") - try: - # Add task_id column to time_entries table - db.engine.execute(text("ALTER TABLE time_entries ADD COLUMN task_id INTEGER REFERENCES tasks(id)")) - print("✓ task_id column added to time_entries table") - except Exception as e: - print(f"⚠ Warning: Could not add task_id column: {e}") - print(" You may need to manually add this column or recreate the database") - else: - print("Task Management: task_id column already exists in time_entries table") - - print("Task Management migration check completed") - - except Exception as e: - print(f"⚠ Warning: Task Management migration check failed: {e}") - print(" The application will continue, but Task Management features may not work properly") - - -def migrate_issues_table(): - """Check and migrate Issues table if it doesn't exist""" - try: - from sqlalchemy import inspect - - # Check if issues table exists - inspector = inspect(db.engine) - existing_tables = inspector.get_table_names() - - if "issues" not in existing_tables: - print("Issues: Creating issues table...") - # Import Issue model to ensure it's registered - from app.models import Issue - # Create the issues table - Issue.__table__.create(db.engine, checkfirst=True) - print("✓ Issues table created successfully") - else: - print("Issues: Issues table already exists") - - print("Issues migration check completed") - - except Exception as e: - print(f"⚠ Warning: Issues migration check failed: {e}") - print(" The application will continue, but Issues features may not work properly") - - def init_database(app): """Initialize database tables and create default admin user""" with app.app_context(): @@ -1484,7 +1336,9 @@ def init_database(app): db.create_all() # Check and migrate Task Management tables if needed + from app.utils.legacy_migrations import migrate_task_management_tables, migrate_issues_table migrate_task_management_tables() + migrate_issues_table() # Create default admin user or demo user if it doesn't exist if app.config.get("DEMO_MODE"): diff --git a/app/blueprint_registry.py b/app/blueprint_registry.py index 2289b0ad..8c99b4a7 100644 --- a/app/blueprint_registry.py +++ b/app/blueprint_registry.py @@ -15,6 +15,16 @@ def register_all_blueprints(app, logger=None): from app.routes.api import api_bp 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_deals import api_v1_deals_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 @@ -69,6 +79,16 @@ def register_all_blueprints(app, logger=None): app.register_blueprint(api_bp) app.register_blueprint(api_v1_bp) app.register_blueprint(api_v1_time_entries_bp) + app.register_blueprint(api_v1_projects_bp) + app.register_blueprint(api_v1_tasks_bp) + app.register_blueprint(api_v1_clients_bp) + app.register_blueprint(api_v1_invoices_bp) + app.register_blueprint(api_v1_expenses_bp) + app.register_blueprint(api_v1_payments_bp) + app.register_blueprint(api_v1_mileage_bp) + app.register_blueprint(api_v1_deals_bp) + app.register_blueprint(api_v1_leads_bp) + app.register_blueprint(api_v1_contacts_bp) app.register_blueprint(api_docs_bp) app.register_blueprint(swaggerui_blueprint) app.register_blueprint(analytics_bp) diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 8d515436..72f5c287 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -232,2081 +232,13 @@ def auth_login(): return jsonify({"token": plain_token}) -# ==================== Projects ==================== +# Projects and Tasks routes are in api_v1_projects.py and api_v1_tasks.py (sub-blueprints) -@api_v1_bp.route("/projects", methods=["GET"]) -@require_api_token("read:projects") -def list_projects(): - """List all projects - --- - tags: - - Projects - parameters: - - name: status - in: query - type: string - enum: [active, archived, on_hold] - - name: client_id - in: query - type: integer - - name: page - in: query - type: integer - - name: per_page - in: query - type: integer - security: - - Bearer: [] - responses: - 200: - description: List of projects - """ - from app.services import ProjectService - from app.utils.scope_filter import get_allowed_client_ids +# Clients and Invoices routes are in api_v1_clients.py and api_v1_invoices.py (sub-blueprints) - # Filter by status - status = request.args.get("status", "active") - client_id = request.args.get("client_id", type=int) - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 20, type=int) - scope_client_ids = get_allowed_client_ids(g.api_user) - project_service = ProjectService() - result = project_service.list_projects( - status=status, - client_id=client_id, - page=page, - per_page=per_page, - scope_client_ids=scope_client_ids, - ) - pag = result["pagination"] - pagination_dict = { - "page": pag.page, - "per_page": pag.per_page, - "total": pag.total, - "pages": pag.pages, - "has_next": pag.has_next, - "has_prev": pag.has_prev, - "next_page": pag.page + 1 if pag.has_next else None, - "prev_page": pag.page - 1 if pag.has_prev else None, - } - return jsonify({"projects": [p.to_dict() for p in result["projects"]], "pagination": pagination_dict}) - - -@api_v1_bp.route("/projects/", methods=["GET"]) -@require_api_token("read:projects") -def get_project(project_id): - """Get a specific project - --- - tags: - - Projects - parameters: - - name: project_id - in: path - type: integer - required: true - security: - - Bearer: [] - responses: - 200: - description: Project details - 404: - description: Project not found - """ - from app.services import ProjectService - from app.utils.scope_filter import user_can_access_project - - project_service = ProjectService() - result = project_service.get_project_with_details(project_id=project_id, include_time_entries=False) - - if not result: - return not_found_response("Project", project_id) - if not user_can_access_project(g.api_user, project_id): - return jsonify({"error": "Access denied", "message": "You do not have access to this project"}), 403 - - return jsonify({"project": result.to_dict()}) - - -@api_v1_bp.route("/projects", methods=["POST"]) -@require_api_token("write:projects") -def create_project(): - """Create a new project - --- - tags: - - Projects - parameters: - - name: body - in: body - required: true - schema: - type: object - required: - - name - properties: - name: - type: string - description: - type: string - client_id: - type: integer - hourly_rate: - type: number - estimated_hours: - type: number - status: - type: string - enum: [active, archived, on_hold] - security: - - Bearer: [] - responses: - 201: - description: Project created - 400: - description: Invalid input - """ - from app.services import ProjectService - - data = request.get_json() or {} - - # Validate required fields - if not data.get("name"): - return jsonify({"error": "Project name is required"}), 400 - - # Use service layer to create project - project_service = ProjectService() - result = project_service.create_project( - name=data["name"], - client_id=data.get("client_id"), - created_by=g.api_user.id, - description=data.get("description"), - billable=data.get("billable", True), - hourly_rate=data.get("hourly_rate"), - code=data.get("code"), - budget_amount=data.get("budget_amount"), - budget_threshold_percent=data.get("budget_threshold_percent"), - billing_ref=data.get("billing_ref"), - ) - - if not result.get("success"): - return error_response(result.get("message", "Could not create project"), status_code=400) - - return jsonify({"message": "Project created successfully", "project": result["project"].to_dict()}), 201 - - -@api_v1_bp.route("/projects/", methods=["PUT", "PATCH"]) -@require_api_token("write:projects") -def update_project(project_id): - """Update a project - --- - tags: - - Projects - parameters: - - name: project_id - in: path - type: integer - required: true - - name: body - in: body - schema: - type: object - security: - - Bearer: [] - responses: - 200: - description: Project updated - 404: - description: Project not found - """ - from app.services import ProjectService - - data = request.get_json() or {} - - # Use service layer to update project - project_service = ProjectService() - - # Prepare update kwargs - update_kwargs = {} - if "name" in data: - update_kwargs["name"] = data["name"] - if "description" in data: - update_kwargs["description"] = data["description"] - if "client_id" in data: - update_kwargs["client_id"] = data["client_id"] - if "hourly_rate" in data: - update_kwargs["hourly_rate"] = data["hourly_rate"] - if "estimated_hours" in data: - update_kwargs["estimated_hours"] = data["estimated_hours"] - if "status" in data: - update_kwargs["status"] = data["status"] - if "code" in data: - update_kwargs["code"] = data["code"] - if "budget_amount" in data: - update_kwargs["budget_amount"] = data["budget_amount"] - if "billing_ref" in data: - update_kwargs["billing_ref"] = data["billing_ref"] - - result = project_service.update_project(project_id=project_id, user_id=g.api_user.id, **update_kwargs) - - if not result.get("success"): - return error_response(result.get("message", "Could not update project"), status_code=400) - - return jsonify({"message": "Project updated successfully", "project": result["project"].to_dict()}) - - -@api_v1_bp.route("/projects/", methods=["DELETE"]) -@require_api_token("write:projects") -def delete_project(project_id): - """Delete/archive a project - --- - tags: - - Projects - parameters: - - name: project_id - in: path - type: integer - required: true - security: - - Bearer: [] - responses: - 200: - description: Project archived - 404: - description: Project not found - """ - from app.services import ProjectService - - project_service = ProjectService() - result = project_service.archive_project(project_id=project_id, user_id=g.api_user.id, reason="Archived via API") - - if not result.get("success"): - return error_response(result.get("message", "Could not archive project"), status_code=404) - - return jsonify({"message": "Project archived successfully"}) - - -# Time entries and timer routes are in api_v1_time_entries.py (api_v1_time_entries_bp) - - -# ==================== Tasks ==================== - - -@api_v1_bp.route("/tasks", methods=["GET"]) -@require_api_token("read:tasks") -def list_tasks(): - """List tasks - --- - tags: - - Tasks - parameters: - - name: project_id - in: query - type: integer - - name: status - in: query - type: string - - name: page - in: query - type: integer - - name: per_page - in: query - type: integer - security: - - Bearer: [] - responses: - 200: - description: List of tasks - """ - from app.services import TaskService - - # Filter by project - project_id = request.args.get("project_id", type=int) - status = request.args.get("status") - tags = request.args.get("tags", "").strip() or None - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) - - # Use service layer with eager loading to avoid N+1 queries - task_service = TaskService() - result = task_service.list_tasks( - project_id=project_id, - status=status, - tags=tags, - page=page, - per_page=per_page, - ) - - # Convert pagination object to dict - pagination = result["pagination"] - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - - return jsonify({"tasks": [t.to_dict() for t in result["tasks"]], "pagination": pagination_dict}) - - -@api_v1_bp.route("/tasks/", methods=["GET"]) -@require_api_token("read:tasks") -def get_task(task_id): - """Get a specific task - --- - tags: - - Tasks - parameters: - - name: task_id - in: path - type: integer - required: true - security: - - Bearer: [] - responses: - 200: - description: Task details - 404: - description: Task not found - """ - from sqlalchemy.orm import joinedload - from app.models import Task - - task = ( - Task.query.options(joinedload(Task.project), joinedload(Task.assignee), joinedload(Task.created_by_user)) - .filter_by(id=task_id) - .first_or_404() - ) - - return jsonify({"task": task.to_dict()}) - - -@api_v1_bp.route("/tasks", methods=["POST"]) -@require_api_token("write:tasks") -def create_task(): - """Create a new task - --- - tags: - - Tasks - parameters: - - name: body - in: body - required: true - schema: - type: object - required: - - name - - project_id - properties: - name: - type: string - description: - type: string - project_id: - type: integer - status: - type: string - priority: - type: integer - security: - - Bearer: [] - responses: - 201: - description: Task created - 400: - description: Invalid input - """ - from app.services import TaskService - - data = request.get_json() or {} - - # Validate required fields - if not data.get("name"): - return jsonify({"error": "Task name is required"}), 400 - if not data.get("project_id"): - return jsonify({"error": "project_id is required"}), 400 - - # Use service layer to create task - task_service = TaskService() - result = task_service.create_task( - name=data["name"], - project_id=data["project_id"], - created_by=g.api_user.id, - description=data.get("description"), - assignee_id=data.get("assignee_id"), - priority=data.get("priority", "medium"), - due_date=data.get("due_date"), - estimated_hours=data.get("estimated_hours"), - tags=data.get("tags"), - ) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create task")}), 400 - - return jsonify({"message": "Task created successfully", "task": result["task"].to_dict()}), 201 - - -@api_v1_bp.route("/tasks/", methods=["PUT", "PATCH"]) -@require_api_token("write:tasks") -def update_task(task_id): - """Update a task - --- - tags: - - Tasks - parameters: - - name: task_id - in: path - type: integer - required: true - - name: body - in: body - schema: - type: object - security: - - Bearer: [] - responses: - 200: - description: Task updated - 404: - description: Task not found - """ - from app.services import TaskService - - data = request.get_json() or {} - - # Use service layer to update task - task_service = TaskService() - - # Prepare update kwargs - update_kwargs = {} - if "name" in data: - update_kwargs["name"] = data["name"] - if "description" in data: - update_kwargs["description"] = data["description"] - if "status" in data: - update_kwargs["status"] = data["status"] - if "priority" in data: - update_kwargs["priority"] = data["priority"] - if "assignee_id" in data: - update_kwargs["assignee_id"] = data["assignee_id"] - if "due_date" in data: - update_kwargs["due_date"] = data["due_date"] - if "estimated_hours" in data: - update_kwargs["estimated_hours"] = data["estimated_hours"] - if "tags" in data: - update_kwargs["tags"] = data["tags"] - - result = task_service.update_task(task_id=task_id, user_id=g.api_user.id, **update_kwargs) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update task")}), 400 - - return jsonify({"message": "Task updated successfully", "task": result["task"].to_dict()}) - - -@api_v1_bp.route("/tasks/", methods=["DELETE"]) -@require_api_token("write:tasks") -def delete_task(task_id): - """Delete a task - --- - tags: - - Tasks - parameters: - - name: task_id - in: path - type: integer - required: true - security: - - Bearer: [] - responses: - 200: - description: Task deleted - 404: - description: Task not found - """ - from app.services import TaskService - - task_service = TaskService() - # For now, use repository directly (can add delete_task method to service later) - from app.repositories import TaskRepository - - task_repo = TaskRepository() - task = task_repo.get_by_id(task_id) - - if not task: - return jsonify({"error": "Task not found"}), 404 - - db.session.delete(task) - db.session.commit() - - return jsonify({"message": "Task deleted successfully"}) - - -# ==================== Clients ==================== - - -@api_v1_bp.route("/clients", methods=["GET"]) -@require_api_token("read:clients") -def list_clients(): - """List all clients - --- - tags: - - Clients - parameters: - - name: page - in: query - type: integer - - name: per_page - in: query - type: integer - security: - - Bearer: [] - responses: - 200: - description: List of clients - """ - blocked = _require_module_enabled_for_api("clients") - if blocked: - return blocked - from app.services import ClientService - - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) - - # Use repository with eager loading (clients don't have many relations, but good practice) - from app.repositories import ClientRepository - from app.utils.scope_filter import apply_client_scope_to_model - - client_repo = ClientRepository() - query = client_repo.query().order_by(Client.name) - scope = apply_client_scope_to_model(Client, g.api_user) - if scope is not None: - query = query.filter(scope) - - # Paginate - pagination = query.paginate(page=page, per_page=per_page, error_out=False) - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - - return jsonify({"clients": [c.to_dict() for c in pagination.items], "pagination": pagination_dict}) - - -@api_v1_bp.route("/clients/", methods=["GET"]) -@require_api_token("read:clients") -def get_client(client_id): - """Get a specific client - --- - tags: - - Clients - parameters: - - name: client_id - in: path - type: integer - required: true - security: - - Bearer: [] - responses: - 200: - description: Client details - 404: - description: Client not found - """ - blocked = _require_module_enabled_for_api("clients") - if blocked: - return blocked - from sqlalchemy.orm import joinedload - from app.utils.scope_filter import user_can_access_client - - client = Client.query.options(joinedload(Client.projects)).filter_by(id=client_id).first_or_404() - if not user_can_access_client(g.api_user, client_id): - return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 - - return jsonify({"client": client.to_dict()}) - - -@api_v1_bp.route("/clients", methods=["POST"]) -@require_api_token("write:clients") -def create_client(): - """Create a new client - --- - tags: - - Clients - parameters: - - name: body - in: body - required: true - schema: - type: object - required: - - name - properties: - name: - type: string - email: - type: string - company: - type: string - phone: - type: string - security: - - Bearer: [] - responses: - 201: - description: Client created - 400: - description: Invalid input - """ - blocked = _require_module_enabled_for_api("clients") - if blocked: - return blocked - data = request.get_json() or {} - - # Validate required fields - if not data.get("name"): - return jsonify({"error": "Client name is required"}), 400 - - from app.services import ClientService - from decimal import Decimal - - # Use service layer to create client - client_service = ClientService() - result = client_service.create_client( - name=data["name"], - created_by=g.api_user.id, - email=data.get("email"), - company=data.get("company"), - phone=data.get("phone"), - address=data.get("address"), - default_hourly_rate=Decimal(str(data["default_hourly_rate"])) if data.get("default_hourly_rate") else None, - custom_fields=data.get("custom_fields"), - ) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create client")}), 400 - - return jsonify({"message": "Client created successfully", "client": result["client"].to_dict()}), 201 - - -# ==================== Invoices ==================== - - -@api_v1_bp.route("/invoices", methods=["GET"]) -@require_api_token("read:invoices") -def list_invoices(): - """List invoices - --- - tags: - - Invoices - parameters: - - name: status - in: query - type: string - - name: client_id - in: query - type: integer - - name: project_id - in: query - type: integer - - name: page - in: query - type: integer - - name: per_page - in: query - type: integer - security: - - Bearer: [] - responses: - 200: - description: List of invoices - """ - from app.services import InvoiceService - - status = request.args.get("status") - client_id = request.args.get("client_id", type=int) - project_id = request.args.get("project_id", type=int) - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) - - # Use service layer with eager loading to avoid N+1 queries - invoice_service = InvoiceService() - result = invoice_service.list_invoices( - status=status, - user_id=g.api_user.id if not g.api_user.is_admin else None, - is_admin=g.api_user.is_admin, - page=page, - per_page=per_page, - ) - - # Convert pagination object to dict - pagination = result["pagination"] - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - - return jsonify({"invoices": [inv.to_dict() for inv in result["invoices"]], "pagination": pagination_dict}) - - -@api_v1_bp.route("/invoices/", methods=["GET"]) -@require_api_token("read:invoices") -def get_invoice(invoice_id): - """Get invoice by id - --- - tags: - - Invoices - security: - - Bearer: [] - responses: - 200: - description: Invoice - 404: - description: Not found - """ - from sqlalchemy.orm import joinedload - from app.models import Invoice - - invoice = ( - Invoice.query.options(joinedload(Invoice.project), joinedload(Invoice.client)) - .filter_by(id=invoice_id) - .first_or_404() - ) - - return jsonify({"invoice": invoice.to_dict()}) - - -@api_v1_bp.route("/invoices", methods=["POST"]) -@require_api_token("write:invoices") -def create_invoice(): - """Create a new invoice - --- - tags: - - Invoices - parameters: - - name: body - in: body - required: true - schema: - type: object - required: - - project_id - - client_id - - client_name - - due_date - properties: - invoice_number: { type: string } - project_id: { type: integer } - client_id: { type: integer } - client_name: { type: string } - client_email: { type: string } - client_address: { type: string } - due_date: { type: string, format: date } - tax_rate: { type: number } - currency_code: { type: string } - notes: { type: string } - terms: { type: string } - security: - - Bearer: [] - responses: - 201: - description: Invoice created - 400: - description: Invalid input - """ - from app.services import InvoiceService - from datetime import date - - data = request.get_json() or {} - - # Validate required fields - required = ["project_id", "client_id", "client_name", "due_date"] - missing = [f for f in required if not data.get(f)] - if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 - - # Parse due date - due_dt = _parse_date(data.get("due_date")) - if not due_dt: - return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400 - - # Parse issue date if provided - issue_dt = None - if data.get("issue_date"): - issue_dt = _parse_date(data.get("issue_date")) - if not issue_dt: - return jsonify({"error": "Invalid issue_date format, expected YYYY-MM-DD"}), 400 - - # Use service layer to create invoice - invoice_service = InvoiceService() - result = invoice_service.create_invoice( - project_id=data["project_id"], - client_id=data["client_id"], - client_name=data["client_name"], - due_date=due_dt, - created_by=g.api_user.id, - invoice_number=data.get("invoice_number"), - client_email=data.get("client_email"), - client_address=data.get("client_address"), - notes=data.get("notes"), - terms=data.get("terms"), - tax_rate=data.get("tax_rate"), - currency_code=data.get("currency_code"), - issue_date=issue_dt, - ) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create invoice")}), 400 - - return jsonify({"message": "Invoice created successfully", "invoice": result["invoice"].to_dict()}), 201 - - -@api_v1_bp.route("/invoices/", methods=["PUT", "PATCH"]) -@require_api_token("write:invoices") -def update_invoice(invoice_id): - """Update an invoice - --- - tags: - - Invoices - security: - - Bearer: [] - responses: - 200: - description: Invoice updated - 404: - description: Not found - """ - from app.services import InvoiceService - - data = request.get_json() or {} - - # Prepare update kwargs - update_kwargs = {} - for field in ("client_name", "client_email", "client_address", "notes", "terms", "status", "currency_code"): - if field in data: - update_kwargs[field] = data[field] - if "due_date" in data: - parsed = _parse_date(data["due_date"]) - if parsed: - update_kwargs["due_date"] = parsed - if "tax_rate" in data: - try: - update_kwargs["tax_rate"] = float(data["tax_rate"]) - except (ValueError, TypeError) as e: - # Invalid tax rate format - log and skip - current_app.logger.warning(f"Invalid tax_rate value in invoice update: {data.get('tax_rate')} - {e}") - if "amount_paid" in data: - try: - from decimal import Decimal, InvalidOperation - - update_kwargs["amount_paid"] = Decimal(str(data["amount_paid"])) - except (ValueError, TypeError, InvalidOperation) as e: - # Invalid amount format - log and skip - current_app.logger.warning(f"Invalid amount_paid value in invoice update: {data.get('amount_paid')} - {e}") - - # Use service layer to update invoice - invoice_service = InvoiceService() - result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, **update_kwargs) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update invoice")}), 400 - - # Handle amount_paid update separately (updates payment status) - if "amount_paid" in data: - invoice = result["invoice"] - invoice.update_payment_status() - db.session.commit() - - return jsonify({"message": "Invoice updated successfully", "invoice": result["invoice"].to_dict()}) - - -@api_v1_bp.route("/invoices/", methods=["DELETE"]) -@require_api_token("write:invoices") -def delete_invoice(invoice_id): - """Cancel an invoice (soft-delete) - --- - tags: - - Invoices - security: - - Bearer: [] - responses: - 200: - description: Invoice cancelled - 404: - description: Not found - """ - from app.services import InvoiceService - - invoice_service = InvoiceService() - result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, status="cancelled") - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not cancel invoice")}), 400 - - return jsonify({"message": "Invoice cancelled successfully"}) - - -# ==================== Expenses ==================== - - -@api_v1_bp.route("/expenses", methods=["GET"]) -@require_api_token("read:expenses") -def list_expenses(): - """List expenses - --- - tags: - - Expenses - parameters: - - name: user_id - in: query - type: integer - - name: project_id - in: query - type: integer - - name: client_id - in: query - type: integer - - name: status - in: query - type: string - - name: category - in: query - type: string - - name: start_date - in: query - type: string - format: date - - name: end_date - in: query - type: string - format: date - - name: page - in: query - type: integer - - name: per_page - in: query - type: integer - security: - - Bearer: [] - responses: - 200: - description: List of expenses - """ - from app.services import ExpenseService - from datetime import date - - # Restrict by user if not admin - user_id = request.args.get("user_id", type=int) - if user_id: - if not g.api_user.is_admin and user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 - else: - if not g.api_user.is_admin: - user_id = g.api_user.id - - # Other filters - project_id = request.args.get("project_id", type=int) - client_id = request.args.get("client_id", type=int) - status = request.args.get("status") - category = request.args.get("category") - start_date = _parse_date(request.args.get("start_date")) - end_date = _parse_date(request.args.get("end_date")) - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) - - # Use service layer with eager loading to avoid N+1 queries - expense_service = ExpenseService() - result = expense_service.list_expenses( - user_id=user_id, - project_id=project_id, - client_id=client_id, - status=status, - category=category, - start_date=start_date, - end_date=end_date, - is_admin=g.api_user.is_admin, - page=page, - per_page=per_page, - ) - - # Convert pagination object to dict - pagination = result["pagination"] - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - - return jsonify({"expenses": [e.to_dict() for e in result["expenses"]], "pagination": pagination_dict}) - - -@api_v1_bp.route("/expenses/", methods=["GET"]) -@require_api_token("read:expenses") -def get_expense(expense_id): - """Get an expense - --- - tags: - - Expenses - security: - - Bearer: [] - responses: - 200: - description: Expense - 404: - description: Not found - """ - from sqlalchemy.orm import joinedload - from app.models import Expense - - expense = ( - Expense.query.options(joinedload(Expense.project), joinedload(Expense.user), joinedload(Expense.client)) - .filter_by(id=expense_id) - .first_or_404() - ) - - if not g.api_user.is_admin and expense.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 - - return jsonify({"expense": expense.to_dict()}) - - -@api_v1_bp.route("/expenses", methods=["POST"]) -@require_api_token("write:expenses") -def create_expense(): - """Create a new expense - --- - tags: - - Expenses - parameters: - - name: body - in: body - required: true - schema: - type: object - required: - - title - - category - - amount - - expense_date - properties: - title: { type: string } - description: { type: string } - category: { type: string } - amount: { type: number } - currency_code: { type: string } - expense_date: { type: string, format: date } - project_id: { type: integer } - client_id: { type: integer } - billable: { type: boolean } - reimbursable: { type: boolean } - payment_method: { type: string } - payment_date: { type: string, format: date } - tags: { type: string } - security: - - Bearer: [] - responses: - 201: - description: Expense created - 400: - description: Invalid input - """ - from app.services import ExpenseService - from decimal import Decimal - - data = request.get_json() or {} - required = ["title", "category", "amount", "expense_date"] - missing = [f for f in required if not data.get(f)] - if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 - - exp_date = _parse_date(data.get("expense_date")) - if not exp_date: - return jsonify({"error": "Invalid expense_date format, expected YYYY-MM-DD"}), 400 - - pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else None - - try: - amount = Decimal(str(data["amount"])) - except Exception: - return jsonify({"error": "Invalid amount"}), 400 - - # Use service layer to create expense - expense_service = ExpenseService() - result = expense_service.create_expense( - amount=amount, - expense_date=exp_date, - created_by=g.api_user.id, - title=data["title"], - description=data.get("description"), - project_id=data.get("project_id"), - client_id=data.get("client_id"), - category=data["category"], - billable=data.get("billable", False), - reimbursable=data.get("reimbursable", True), - currency_code=data.get("currency_code", "EUR"), - tax_amount=Decimal(str(data.get("tax_amount", 0))) if data.get("tax_amount") else None, - tax_rate=Decimal(str(data.get("tax_rate", 0))) if data.get("tax_rate") else None, - payment_method=data.get("payment_method"), - payment_date=pay_date, - tags=data.get("tags"), - ) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create expense")}), 400 - - return jsonify({"message": "Expense created successfully", "expense": result["expense"].to_dict()}), 201 - - -@api_v1_bp.route("/expenses/", methods=["PUT", "PATCH"]) -@require_api_token("write:expenses") -def update_expense(expense_id): - """Update an expense - --- - tags: - - Expenses - security: - - Bearer: [] - responses: - 200: - description: Expense updated - 404: - description: Not found - """ - from app.services import ExpenseService - from decimal import Decimal - - data = request.get_json() or {} - - # Prepare update kwargs - update_kwargs = {} - for field in ("title", "description", "category", "currency_code", "payment_method", "status", "tags"): - if field in data: - update_kwargs[field] = data[field] - if "amount" in data: - try: - update_kwargs["amount"] = Decimal(str(data["amount"])) - except Exception: - pass - if "expense_date" in data: - parsed = _parse_date(data["expense_date"]) - if parsed: - update_kwargs["expense_date"] = parsed - if "payment_date" in data: - parsed = _parse_date(data["payment_date"]) - update_kwargs["payment_date"] = parsed - for bfield in ("billable", "reimbursable", "reimbursed", "invoiced"): - if bfield in data: - update_kwargs[bfield] = bool(data[bfield]) - - # Use service layer to update expense - expense_service = ExpenseService() - result = expense_service.update_expense( - expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin, **update_kwargs - ) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update expense")}), 400 - - return jsonify({"message": "Expense updated successfully", "expense": result["expense"].to_dict()}) - - -@api_v1_bp.route("/expenses/", methods=["DELETE"]) -@require_api_token("write:expenses") -def delete_expense(expense_id): - """Reject an expense (soft-delete) - --- - tags: - - Expenses - security: - - Bearer: [] - responses: - 200: - description: Expense rejected - 404: - description: Not found - """ - from app.services import ExpenseService - - expense_service = ExpenseService() - result = expense_service.delete_expense(expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not reject expense")}), 400 - - return jsonify({"message": "Expense rejected successfully"}) - - -# ==================== Payments ==================== - - -@api_v1_bp.route("/payments", methods=["GET"]) -@require_api_token("read:payments") -def list_payments(): - """List payments - --- - tags: - - Payments - parameters: - - name: invoice_id - in: query - type: integer - - name: page - in: query - type: integer - - name: per_page - in: query - type: integer - security: - - Bearer: [] - responses: - 200: - description: List of payments - """ - from app.services import PaymentService - from sqlalchemy.orm import joinedload - from app.models import Payment - - invoice_id = request.args.get("invoice_id", type=int) - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) - - # Use repository with eager loading to avoid N+1 queries - query = Payment.query.options(joinedload(Payment.invoice)) - - if invoice_id: - query = query.filter(Payment.invoice_id == invoice_id) - - query = query.order_by(Payment.created_at.desc()) - - # Paginate - pagination = query.paginate(page=page, per_page=per_page, error_out=False) - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - - return jsonify({"payments": [p.to_dict() for p in pagination.items], "pagination": pagination_dict}) - - -@api_v1_bp.route("/payments/", methods=["GET"]) -@require_api_token("read:payments") -def get_payment(payment_id): - """Get a payment - --- - tags: - - Payments - security: - - Bearer: [] - responses: - 200: - description: Payment - """ - from sqlalchemy.orm import joinedload - from app.models import Payment - - payment = Payment.query.options(joinedload(Payment.invoice)).filter_by(id=payment_id).first_or_404() - - return jsonify({"payment": payment.to_dict()}) - - -@api_v1_bp.route("/payments", methods=["POST"]) -@require_api_token("write:payments") -def create_payment(): - """Create a payment - --- - tags: - - Payments - parameters: - - name: body - in: body - required: true - schema: - type: object - required: [invoice_id, amount] - properties: - invoice_id: { type: integer } - amount: { type: number } - currency: { type: string } - payment_date: { type: string, format: date } - method: { type: string } - reference: { type: string } - notes: { type: string } - security: - - Bearer: [] - responses: - 201: - description: Payment created - """ - from app.services import PaymentService - from decimal import Decimal - - data = request.get_json() or {} - required = ["invoice_id", "amount"] - missing = [f for f in required if not data.get(f)] - if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 - - try: - amount = Decimal(str(data["amount"])) - except Exception: - return jsonify({"error": "Invalid amount"}), 400 - - pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else None - if not pay_date: - from datetime import date - - pay_date = date.today() - - # Use service layer to create payment - payment_service = PaymentService() - result = payment_service.create_payment( - invoice_id=data["invoice_id"], - amount=amount, - payment_date=pay_date, - received_by=g.api_user.id, - currency=data.get("currency"), - method=data.get("method"), - reference=data.get("reference"), - notes=data.get("notes"), - status=data.get("status", "completed"), - ) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not create payment")}), 400 - - return jsonify({"message": "Payment created successfully", "payment": result["payment"].to_dict()}), 201 - - -@api_v1_bp.route("/payments/", methods=["PUT", "PATCH"]) -@require_api_token("write:payments") -def update_payment(payment_id): - """Update a payment - --- - tags: - - Payments - security: - - Bearer: [] - responses: - 200: - description: Payment updated - """ - from app.services import PaymentService - from decimal import Decimal - - data = request.get_json() or {} - - # Prepare update kwargs - update_kwargs = {} - for field in ("currency", "method", "reference", "notes", "status"): - if field in data: - update_kwargs[field] = data[field] - if "amount" in data: - try: - update_kwargs["amount"] = Decimal(str(data["amount"])) - except Exception: - pass - if "payment_date" in data: - parsed = _parse_date(data["payment_date"]) - if parsed: - update_kwargs["payment_date"] = parsed - - # Use service layer to update payment - payment_service = PaymentService() - result = payment_service.update_payment(payment_id=payment_id, user_id=g.api_user.id, **update_kwargs) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not update payment")}), 400 - - return jsonify({"message": "Payment updated successfully", "payment": result["payment"].to_dict()}) - - -@api_v1_bp.route("/payments/", methods=["DELETE"]) -@require_api_token("write:payments") -def delete_payment(payment_id): - """Delete a payment - --- - tags: - - Payments - security: - - Bearer: [] - responses: - 200: - description: Payment deleted - """ - from app.services import PaymentService - - payment_service = PaymentService() - result = payment_service.delete_payment(payment_id=payment_id, user_id=g.api_user.id) - - if not result.get("success"): - return jsonify({"error": result.get("message", "Could not delete payment")}), 400 - - return jsonify({"message": "Payment deleted successfully"}) - - -# ==================== Mileage ==================== - - -@api_v1_bp.route("/mileage", methods=["GET"]) -@require_api_token("read:mileage") -def list_mileage(): - """List mileage entries (non-admin see own only) - --- - tags: - - Mileage - parameters: - - name: user_id - in: query - type: integer - - name: project_id - in: query - type: integer - - name: start_date - in: query - type: string - format: date - - name: end_date - in: query - type: string - format: date - - name: page - in: query - type: integer - - name: per_page - in: query - type: integer - security: - - Bearer: [] - responses: - 200: - description: List of mileage entries - """ - from sqlalchemy.orm import joinedload - - # Restrict by user if not admin - user_id = request.args.get("user_id", type=int) - if user_id: - if not g.api_user.is_admin and user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 - else: - if not g.api_user.is_admin: - user_id = g.api_user.id - - project_id = request.args.get("project_id", type=int) - start_date = _parse_date(request.args.get("start_date")) - end_date = _parse_date(request.args.get("end_date")) - page = request.args.get("page", 1, type=int) - per_page = request.args.get("per_page", 50, type=int) - - # Use eager loading to avoid N+1 queries - query = Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) - - # Apply filters - if user_id: - query = query.filter(Mileage.user_id == user_id) - if project_id: - query = query.filter(Mileage.project_id == project_id) - if start_date: - query = query.filter(Mileage.trip_date >= start_date) - if end_date: - query = query.filter(Mileage.trip_date <= end_date) - - query = query.order_by(Mileage.trip_date.desc(), Mileage.created_at.desc()) - - # Paginate - pagination = query.paginate(page=page, per_page=per_page, error_out=False) - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - - return jsonify({"mileage": [m.to_dict() for m in pagination.items], "pagination": pagination_dict}) - - -@api_v1_bp.route("/mileage/", methods=["GET"]) -@require_api_token("read:mileage") -def get_mileage(entry_id): - """Get a mileage entry - --- - tags: - - Mileage - security: - - Bearer: [] - """ - from sqlalchemy.orm import joinedload - - entry = ( - Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) - .filter_by(id=entry_id) - .first_or_404() - ) - - if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 - - return jsonify({"mileage": entry.to_dict()}) - - -@api_v1_bp.route("/mileage", methods=["POST"]) -@require_api_token("write:mileage") -def create_mileage(): - """Create a mileage entry - --- - tags: - - Mileage - parameters: - - name: body - in: body - required: true - schema: - type: object - required: [trip_date, purpose, start_location, end_location, distance_km, rate_per_km] - properties: - trip_date: { type: string, format: date } - purpose: { type: string } - start_location: { type: string } - end_location: { type: string } - distance_km: { type: number } - rate_per_km: { type: number } - project_id: { type: integer } - client_id: { type: integer } - is_round_trip: { type: boolean } - """ - data = request.get_json() or {} - required = ["trip_date", "purpose", "start_location", "end_location", "distance_km", "rate_per_km"] - missing = [f for f in required if not data.get(f)] - if missing: - return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 - trip_date = _parse_date(data.get("trip_date")) - if not trip_date: - return jsonify({"error": "Invalid trip_date format, expected YYYY-MM-DD"}), 400 - from decimal import Decimal - - try: - distance_km = Decimal(str(data["distance_km"])) - rate_per_km = Decimal(str(data["rate_per_km"])) - except Exception: - return jsonify({"error": "Invalid distance_km or rate_per_km"}), 400 - entry = Mileage( - user_id=g.api_user.id, - trip_date=trip_date, - purpose=data["purpose"], - start_location=data["start_location"], - end_location=data["end_location"], - distance_km=distance_km, - rate_per_km=rate_per_km, - project_id=data.get("project_id"), - client_id=data.get("client_id"), - is_round_trip=bool(data.get("is_round_trip", False)), - description=data.get("description"), - ) - db.session.add(entry) - db.session.commit() - return jsonify({"message": "Mileage entry created successfully", "mileage": entry.to_dict()}), 201 - - -@api_v1_bp.route("/mileage/", methods=["PUT", "PATCH"]) -@require_api_token("write:mileage") -def update_mileage(entry_id): - """Update a mileage entry - --- - tags: - - Mileage - """ - from sqlalchemy.orm import joinedload - from decimal import Decimal - - entry = ( - Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) - .filter_by(id=entry_id) - .first_or_404() - ) - - if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 - - data = request.get_json() or {} - - # Update fields - for field in ( - "purpose", - "start_location", - "end_location", - "description", - "vehicle_type", - "vehicle_description", - "license_plate", - "currency_code", - "status", - "notes", - ): - if field in data: - setattr(entry, field, data[field]) - if "trip_date" in data: - parsed = _parse_date(data["trip_date"]) - if parsed: - entry.trip_date = parsed - for numfield in ("distance_km", "rate_per_km", "start_odometer", "end_odometer"): - if numfield in data: - try: - setattr(entry, numfield, Decimal(str(data[numfield]))) - except Exception: - pass - if "is_round_trip" in data: - entry.is_round_trip = bool(data["is_round_trip"]) - - # Recalculate amount if distance or rate changed - if "distance_km" in data or "rate_per_km" in data: - entry.calculated_amount = entry.distance_km * entry.rate_per_km - if entry.is_round_trip: - entry.calculated_amount *= Decimal("2") - - db.session.commit() - return jsonify({"message": "Mileage entry updated successfully", "mileage": entry.to_dict()}) - - -@api_v1_bp.route("/mileage/", methods=["DELETE"]) -@require_api_token("write:mileage") -def delete_mileage(entry_id): - """Reject a mileage entry - --- - tags: - - Mileage - """ - from sqlalchemy.orm import joinedload - - entry = ( - Mileage.query.options(joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client)) - .filter_by(id=entry_id) - .first_or_404() - ) - - if not g.api_user.is_admin and entry.user_id != g.api_user.id: - return jsonify({"error": "Access denied"}), 403 - - entry.status = "rejected" - db.session.commit() - return jsonify({"message": "Mileage entry rejected successfully"}) - - -# ==================== Deals (CRM) ==================== - - -@api_v1_bp.route("/deals", methods=["GET"]) -@require_api_token("read:deals") -def list_deals(): - """List deals with optional filters (status, stage, owner_id).""" - blocked = _require_module_enabled_for_api("deals") - if blocked: - return blocked - status = request.args.get("status", "open") - stage = request.args.get("stage", "") - owner_id = request.args.get("owner", type=int) - query = Deal.query - if status == "open": - query = query.filter_by(status="open") - elif status == "won": - query = query.filter_by(status="won") - elif status == "lost": - query = query.filter_by(status="lost") - if stage: - query = query.filter_by(stage=stage) - if owner_id and not g.api_user.is_admin: - query = query.filter_by(owner_id=g.api_user.id) - elif owner_id: - query = query.filter_by(owner_id=owner_id) - query = query.order_by(Deal.expected_close_date, Deal.created_at.desc()) - page = request.args.get("page", 1, type=int) - per_page = min(request.args.get("per_page", 50, type=int), 100) - pagination = query.paginate(page=page, per_page=per_page, error_out=False) - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - return jsonify({"deals": [d.to_dict() for d in pagination.items], "pagination": pagination_dict}) - - -@api_v1_bp.route("/deals/", methods=["GET"]) -@require_api_token("read:deals") -def get_deal(deal_id): - """Get a deal by id.""" - blocked = _require_module_enabled_for_api("deals") - if blocked: - return blocked - deal = Deal.query.filter_by(id=deal_id).first_or_404() - if not g.api_user.is_admin and deal.owner_id != g.api_user.id: - return forbidden_response("Access denied") - return jsonify({"deal": deal.to_dict()}) - - -@api_v1_bp.route("/deals", methods=["POST"]) -@require_api_token("write:deals") -def create_deal(): - """Create a deal.""" - blocked = _require_module_enabled_for_api("deals") - if blocked: - return blocked - data = request.get_json() or {} - name = (data.get("name") or "").strip() - if not name: - return jsonify({"error": "name is required"}), 400 - from decimal import Decimal - value = None - if data.get("value") is not None: - try: - value = Decimal(str(data["value"])) - except Exception: - return error_response("Invalid value", status_code=400) - expected_close_date = _parse_date(data.get("expected_close_date")) - deal = Deal( - name=name, - created_by=g.api_user.id, - client_id=data.get("client_id"), - contact_id=data.get("contact_id"), - lead_id=data.get("lead_id"), - description=(data.get("description") or "").strip() or None, - stage=(data.get("stage") or "prospecting").strip(), - value=value, - currency_code=(data.get("currency_code") or "EUR").strip(), - probability=int(data.get("probability", 50)), - expected_close_date=expected_close_date, - status=(data.get("status") or "open").strip(), - loss_reason=(data.get("loss_reason") or "").strip() or None, - notes=(data.get("notes") or "").strip() or None, - owner_id=data.get("owner_id") or g.api_user.id, - related_quote_id=data.get("related_quote_id"), - related_project_id=data.get("related_project_id"), - ) - db.session.add(deal) - db.session.commit() - return jsonify({"message": "Deal created successfully", "deal": deal.to_dict()}), 201 - - -@api_v1_bp.route("/deals/", methods=["PUT", "PATCH"]) -@require_api_token("write:deals") -def update_deal(deal_id): - """Update a deal.""" - blocked = _require_module_enabled_for_api("deals") - if blocked: - return blocked - deal = Deal.query.filter_by(id=deal_id).first_or_404() - if not g.api_user.is_admin and deal.owner_id != g.api_user.id: - return forbidden_response("Access denied") - data = request.get_json() or {} - from decimal import Decimal - for field in ("name", "description", "stage", "status", "loss_reason", "notes", "currency_code"): - if field in data and data[field] is not None: - setattr(deal, field, str(data[field]).strip() if isinstance(data[field], str) else data[field]) - for field in ("client_id", "contact_id", "lead_id", "probability", "related_quote_id", "related_project_id", "owner_id"): - if field in data: - setattr(deal, field, data[field]) - if "value" in data: - try: - deal.value = Decimal(str(data["value"])) if data["value"] is not None else None - except Exception: - pass - if "expected_close_date" in data: - deal.expected_close_date = _parse_date(data["expected_close_date"]) - if "actual_close_date" in data: - deal.actual_close_date = _parse_date(data["actual_close_date"]) - db.session.commit() - return jsonify({"message": "Deal updated successfully", "deal": deal.to_dict()}) - - -@api_v1_bp.route("/deals/", methods=["DELETE"]) -@require_api_token("write:deals") -def delete_deal(deal_id): - """Delete (or cancel) a deal.""" - blocked = _require_module_enabled_for_api("deals") - if blocked: - return blocked - deal = Deal.query.filter_by(id=deal_id).first_or_404() - if not g.api_user.is_admin and deal.owner_id != g.api_user.id: - return forbidden_response("Access denied") - db.session.delete(deal) - db.session.commit() - return jsonify({"message": "Deal deleted successfully"}) - - -# ==================== Leads (CRM) ==================== - - -@api_v1_bp.route("/leads", methods=["GET"]) -@require_api_token("read:leads") -def list_leads(): - """List leads with optional filters (status, source, owner).""" - blocked = _require_module_enabled_for_api("leads") - if blocked: - return blocked - status = request.args.get("status", "") - source = request.args.get("source", "") - owner_id = request.args.get("owner", type=int) - search = (request.args.get("search") or "").strip() - query = Lead.query - if status: - query = query.filter_by(status=status) - else: - query = query.filter(~Lead.status.in_(["converted", "lost"])) - if source: - query = query.filter_by(source=source) - if owner_id and not g.api_user.is_admin: - query = query.filter_by(owner_id=g.api_user.id) - elif owner_id: - query = query.filter_by(owner_id=owner_id) - if search: - like = f"%{search}%" - query = query.filter(or_(Lead.first_name.ilike(like), Lead.last_name.ilike(like), Lead.company_name.ilike(like), Lead.email.ilike(like))) - query = query.order_by(Lead.score.desc(), Lead.created_at.desc()) - page = request.args.get("page", 1, type=int) - per_page = min(request.args.get("per_page", 50, type=int), 100) - pagination = query.paginate(page=page, per_page=per_page, error_out=False) - pagination_dict = { - "page": pagination.page, - "per_page": pagination.per_page, - "total": pagination.total, - "pages": pagination.pages, - "has_next": pagination.has_next, - "has_prev": pagination.has_prev, - "next_page": pagination.page + 1 if pagination.has_next else None, - "prev_page": pagination.page - 1 if pagination.has_prev else None, - } - return jsonify({"leads": [l.to_dict() for l in pagination.items], "pagination": pagination_dict}) - - -@api_v1_bp.route("/leads/", methods=["GET"]) -@require_api_token("read:leads") -def get_lead(lead_id): - """Get a lead by id.""" - blocked = _require_module_enabled_for_api("leads") - if blocked: - return blocked - lead = Lead.query.filter_by(id=lead_id).first_or_404() - if not g.api_user.is_admin and lead.owner_id != g.api_user.id: - return forbidden_response("Access denied") - return jsonify({"lead": lead.to_dict()}) - - -@api_v1_bp.route("/leads", methods=["POST"]) -@require_api_token("write:leads") -def create_lead(): - """Create a lead.""" - blocked = _require_module_enabled_for_api("leads") - if blocked: - return blocked - data = request.get_json() or {} - first_name = (data.get("first_name") or "").strip() - last_name = (data.get("last_name") or "").strip() - if not first_name or not last_name: - return error_response("first_name and last_name are required", status_code=400) - from decimal import Decimal - estimated_value = None - if data.get("estimated_value") is not None: - try: - estimated_value = Decimal(str(data["estimated_value"])) - except Exception: - pass - lead = Lead( - first_name=first_name, - last_name=last_name, - created_by=g.api_user.id, - company_name=(data.get("company_name") or "").strip() or None, - email=(data.get("email") or "").strip() or None, - phone=(data.get("phone") or "").strip() or None, - title=(data.get("title") or "").strip() or None, - source=(data.get("source") or "").strip() or None, - status=(data.get("status") or "new").strip(), - score=int(data.get("score", 0)), - estimated_value=estimated_value, - currency_code=(data.get("currency_code") or "EUR").strip(), - notes=(data.get("notes") or "").strip() or None, - tags=(data.get("tags") or "").strip() or None, - owner_id=data.get("owner_id") or g.api_user.id, - ) - db.session.add(lead) - db.session.commit() - return jsonify({"message": "Lead created successfully", "lead": lead.to_dict()}), 201 - - -@api_v1_bp.route("/leads/", methods=["PUT", "PATCH"]) -@require_api_token("write:leads") -def update_lead(lead_id): - """Update a lead.""" - blocked = _require_module_enabled_for_api("leads") - if blocked: - return blocked - lead = Lead.query.filter_by(id=lead_id).first_or_404() - if not g.api_user.is_admin and lead.owner_id != g.api_user.id: - return forbidden_response("Access denied") - data = request.get_json() or {} - from decimal import Decimal - for field in ("first_name", "last_name", "company_name", "email", "phone", "title", "source", "status", "notes", "tags"): - if field in data and data[field] is not None: - setattr(lead, field, str(data[field]).strip() if isinstance(data[field], str) else data[field]) - if "score" in data: - lead.score = int(data["score"]) - if "estimated_value" in data: - try: - lead.estimated_value = Decimal(str(data["estimated_value"])) if data["estimated_value"] is not None else None - except Exception: - pass - if "owner_id" in data: - lead.owner_id = data["owner_id"] - db.session.commit() - return jsonify({"message": "Lead updated successfully", "lead": lead.to_dict()}) - - -@api_v1_bp.route("/leads/", methods=["DELETE"]) -@require_api_token("write:leads") -def delete_lead(lead_id): - """Delete a lead.""" - blocked = _require_module_enabled_for_api("leads") - if blocked: - return blocked - lead = Lead.query.filter_by(id=lead_id).first_or_404() - if not g.api_user.is_admin and lead.owner_id != g.api_user.id: - return forbidden_response("Access denied") - db.session.delete(lead) - db.session.commit() - return jsonify({"message": "Lead deleted successfully"}) - - -# ==================== Contacts (CRM) ==================== - - -@api_v1_bp.route("/clients//contacts", methods=["GET"]) -@require_api_token("read:contacts") -def list_contacts(client_id): - """List contacts for a client.""" - blocked = _require_module_enabled_for_api("contacts") - if blocked: - return blocked - client = Client.query.filter_by(id=client_id).first_or_404() - from app.utils.scope_filter import user_can_access_client - if not user_can_access_client(g.api_user, client_id): - return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 - contacts = Contact.get_active_contacts(client_id) - return jsonify({"contacts": [c.to_dict() for c in contacts]}) - - -@api_v1_bp.route("/clients//contacts", methods=["POST"]) -@require_api_token("write:contacts") -def create_contact(client_id): - """Create a contact for a client.""" - blocked = _require_module_enabled_for_api("contacts") - if blocked: - return blocked - client = Client.query.filter_by(id=client_id).first_or_404() - from app.utils.scope_filter import user_can_access_client - if not user_can_access_client(g.api_user, client_id): - return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 - data = request.get_json() or {} - first_name = (data.get("first_name") or "").strip() - last_name = (data.get("last_name") or "").strip() - if not first_name or not last_name: - return error_response("first_name and last_name are required", status_code=400) - contact = Contact( - client_id=client_id, - first_name=first_name, - last_name=last_name, - created_by=g.api_user.id, - email=(data.get("email") or "").strip() or None, - phone=(data.get("phone") or "").strip() or None, - mobile=(data.get("mobile") or "").strip() or None, - title=(data.get("title") or "").strip() or None, - department=(data.get("department") or "").strip() or None, - role=(data.get("role") or "contact").strip(), - is_primary=bool(data.get("is_primary", False)), - address=(data.get("address") or "").strip() or None, - notes=(data.get("notes") or "").strip() or None, - tags=(data.get("tags") or "").strip() or None, - ) - db.session.add(contact) - if contact.is_primary: - Contact.query.filter(Contact.client_id == client_id, Contact.id != contact.id, Contact.is_primary == True).update({"is_primary": False}) - db.session.commit() - return jsonify({"message": "Contact created successfully", "contact": contact.to_dict()}), 201 - - -@api_v1_bp.route("/contacts/", methods=["GET"]) -@require_api_token("read:contacts") -def get_contact(contact_id): - """Get a contact by id.""" - blocked = _require_module_enabled_for_api("contacts") - if blocked: - return blocked - contact = Contact.query.filter_by(id=contact_id).first_or_404() - return jsonify({"contact": contact.to_dict()}) - - -@api_v1_bp.route("/contacts/", methods=["PUT", "PATCH"]) -@require_api_token("write:contacts") -def update_contact(contact_id): - """Update a contact.""" - blocked = _require_module_enabled_for_api("contacts") - if blocked: - return blocked - contact = Contact.query.filter_by(id=contact_id).first_or_404() - data = request.get_json() or {} - for field in ("first_name", "last_name", "email", "phone", "mobile", "title", "department", "role", "address", "notes", "tags"): - if field in data and data[field] is not None: - setattr(contact, field, str(data[field]).strip() if isinstance(data[field], str) else data[field]) - if "is_primary" in data: - contact.is_primary = bool(data["is_primary"]) - if contact.is_primary: - Contact.query.filter(Contact.client_id == contact.client_id, Contact.id != contact.id, Contact.is_primary == True).update({"is_primary": False}) - db.session.commit() - return jsonify({"message": "Contact updated successfully", "contact": contact.to_dict()}) - - -@api_v1_bp.route("/contacts/", methods=["DELETE"]) -@require_api_token("write:contacts") -def delete_contact(contact_id): - """Soft-delete a contact (set is_active=False).""" - blocked = _require_module_enabled_for_api("contacts") - if blocked: - return blocked - contact = Contact.query.filter_by(id=contact_id).first_or_404() - contact.is_active = False - db.session.commit() - return jsonify({"message": "Contact deleted successfully"}) +# Expenses, Payments, Mileage, Deals, Leads, Contacts are in api_v1_* sub-blueprints # ==================== Time Entry Approvals ==================== diff --git a/app/routes/api_v1_clients.py b/app/routes/api_v1_clients.py new file mode 100644 index 00000000..38cbab59 --- /dev/null +++ b/app/routes/api_v1_clients.py @@ -0,0 +1,87 @@ +""" +API v1 - Clients sub-blueprint. +Routes under /api/v1/clients. +""" + +from flask import Blueprint, jsonify, request, g +from app.models import Client +from app.utils.api_auth import require_api_token +from app.routes.api_v1_common import _require_module_enabled_for_api + +api_v1_clients_bp = Blueprint("api_v1_clients", __name__, url_prefix="/api/v1") + + +@api_v1_clients_bp.route("/clients", methods=["GET"]) +@require_api_token("read:clients") +def list_clients(): + """List all clients.""" + blocked = _require_module_enabled_for_api("clients") + if blocked: + return blocked + from app.repositories import ClientRepository + from app.utils.scope_filter import apply_client_scope_to_model + + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 50, type=int) + client_repo = ClientRepository() + query = client_repo.query().order_by(Client.name) + scope = apply_client_scope_to_model(Client, g.api_user) + if scope is not None: + query = query.filter(scope) + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"clients": [c.to_dict() for c in pagination.items], "pagination": pagination_dict}) + + +@api_v1_clients_bp.route("/clients/", methods=["GET"]) +@require_api_token("read:clients") +def get_client(client_id): + """Get a specific client.""" + blocked = _require_module_enabled_for_api("clients") + if blocked: + return blocked + from sqlalchemy.orm import joinedload + from app.utils.scope_filter import user_can_access_client + + client = Client.query.options(joinedload(Client.projects)).filter_by(id=client_id).first_or_404() + if not user_can_access_client(g.api_user, client_id): + return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 + return jsonify({"client": client.to_dict()}) + + +@api_v1_clients_bp.route("/clients", methods=["POST"]) +@require_api_token("write:clients") +def create_client(): + """Create a new client.""" + blocked = _require_module_enabled_for_api("clients") + if blocked: + return blocked + from decimal import Decimal + from app.services import ClientService + + data = request.get_json() or {} + if not data.get("name"): + return jsonify({"error": "Client name is required"}), 400 + client_service = ClientService() + result = client_service.create_client( + name=data["name"], + created_by=g.api_user.id, + email=data.get("email"), + company=data.get("company"), + phone=data.get("phone"), + address=data.get("address"), + default_hourly_rate=Decimal(str(data["default_hourly_rate"])) if data.get("default_hourly_rate") else None, + custom_fields=data.get("custom_fields"), + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not create client")}), 400 + return jsonify({"message": "Client created successfully", "client": result["client"].to_dict()}), 201 diff --git a/app/routes/api_v1_contacts.py b/app/routes/api_v1_contacts.py new file mode 100644 index 00000000..03154dc1 --- /dev/null +++ b/app/routes/api_v1_contacts.py @@ -0,0 +1,125 @@ +""" +API v1 - Contacts (CRM) sub-blueprint. +Routes under /api/v1/clients//contacts and /api/v1/contacts. +""" + +from flask import Blueprint, jsonify, request, g +from app import db +from app.models import Client, Contact +from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response +from app.routes.api_v1_common import _require_module_enabled_for_api + +api_v1_contacts_bp = Blueprint("api_v1_contacts", __name__, url_prefix="/api/v1") + + +@api_v1_contacts_bp.route("/clients//contacts", methods=["GET"]) +@require_api_token("read:contacts") +def list_contacts(client_id): + """List contacts for a client.""" + blocked = _require_module_enabled_for_api("contacts") + if blocked: + return blocked + from app.utils.scope_filter import user_can_access_client + + client = Client.query.filter_by(id=client_id).first_or_404() + if not user_can_access_client(g.api_user, client_id): + return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 + contacts = Contact.get_active_contacts(client_id) + return jsonify({"contacts": [c.to_dict() for c in contacts]}) + + +@api_v1_contacts_bp.route("/clients//contacts", methods=["POST"]) +@require_api_token("write:contacts") +def create_contact(client_id): + """Create a contact for a client.""" + blocked = _require_module_enabled_for_api("contacts") + if blocked: + return blocked + from app.utils.scope_filter import user_can_access_client + + client = Client.query.filter_by(id=client_id).first_or_404() + if not user_can_access_client(g.api_user, client_id): + return jsonify({"error": "Access denied", "message": "You do not have access to this client"}), 403 + data = request.get_json() or {} + first_name = (data.get("first_name") or "").strip() + last_name = (data.get("last_name") or "").strip() + if not first_name or not last_name: + return error_response("first_name and last_name are required", status_code=400) + contact = Contact( + client_id=client_id, + first_name=first_name, + last_name=last_name, + created_by=g.api_user.id, + email=(data.get("email") or "").strip() or None, + phone=(data.get("phone") or "").strip() or None, + mobile=(data.get("mobile") or "").strip() or None, + title=(data.get("title") or "").strip() or None, + department=(data.get("department") or "").strip() or None, + role=(data.get("role") or "contact").strip(), + is_primary=bool(data.get("is_primary", False)), + address=(data.get("address") or "").strip() or None, + notes=(data.get("notes") or "").strip() or None, + tags=(data.get("tags") or "").strip() or None, + ) + db.session.add(contact) + if contact.is_primary: + Contact.query.filter( + Contact.client_id == client_id, Contact.id != contact.id, Contact.is_primary == True + ).update({"is_primary": False}) + db.session.commit() + return jsonify({"message": "Contact created successfully", "contact": contact.to_dict()}), 201 + + +@api_v1_contacts_bp.route("/contacts/", methods=["GET"]) +@require_api_token("read:contacts") +def get_contact(contact_id): + """Get a contact by id.""" + blocked = _require_module_enabled_for_api("contacts") + if blocked: + return blocked + contact = Contact.query.filter_by(id=contact_id).first_or_404() + return jsonify({"contact": contact.to_dict()}) + + +@api_v1_contacts_bp.route("/contacts/", methods=["PUT", "PATCH"]) +@require_api_token("write:contacts") +def update_contact(contact_id): + """Update a contact.""" + blocked = _require_module_enabled_for_api("contacts") + if blocked: + return blocked + contact = Contact.query.filter_by(id=contact_id).first_or_404() + data = request.get_json() or {} + for field in ( + "first_name", "last_name", "email", "phone", "mobile", "title", + "department", "role", "address", "notes", "tags", + ): + if field in data and data[field] is not None: + setattr( + contact, field, + str(data[field]).strip() if isinstance(data[field], str) else data[field], + ) + if "is_primary" in data: + contact.is_primary = bool(data["is_primary"]) + if contact.is_primary: + Contact.query.filter( + Contact.client_id == contact.client_id, + Contact.id != contact.id, + Contact.is_primary == True, + ).update({"is_primary": False}) + db.session.commit() + return jsonify({"message": "Contact updated successfully", "contact": contact.to_dict()}) + + +@api_v1_contacts_bp.route("/contacts/", methods=["DELETE"]) +@require_api_token("write:contacts") +def delete_contact(contact_id): + """Soft-delete a contact (set is_active=False).""" + blocked = _require_module_enabled_for_api("contacts") + if blocked: + return blocked + contact = Contact.query.filter_by(id=contact_id).first_or_404() + contact.is_active = False + db.session.commit() + return jsonify({"message": "Contact deleted successfully"}) diff --git a/app/routes/api_v1_deals.py b/app/routes/api_v1_deals.py new file mode 100644 index 00000000..8cf88751 --- /dev/null +++ b/app/routes/api_v1_deals.py @@ -0,0 +1,154 @@ +""" +API v1 - Deals (CRM) sub-blueprint. +Routes under /api/v1/deals. +""" + +from flask import Blueprint, jsonify, request, g +from decimal import Decimal +from app import db +from app.models import Deal +from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, forbidden_response +from app.routes.api_v1_common import _parse_date, _require_module_enabled_for_api + +api_v1_deals_bp = Blueprint("api_v1_deals", __name__, url_prefix="/api/v1") + + +@api_v1_deals_bp.route("/deals", methods=["GET"]) +@require_api_token("read:deals") +def list_deals(): + """List deals with optional filters.""" + blocked = _require_module_enabled_for_api("deals") + if blocked: + return blocked + status = request.args.get("status", "open") + stage = request.args.get("stage", "") + owner_id = request.args.get("owner", type=int) + query = Deal.query + if status == "open": + query = query.filter_by(status="open") + elif status == "won": + query = query.filter_by(status="won") + elif status == "lost": + query = query.filter_by(status="lost") + if stage: + query = query.filter_by(stage=stage) + if owner_id and not g.api_user.is_admin: + query = query.filter_by(owner_id=g.api_user.id) + elif owner_id: + query = query.filter_by(owner_id=owner_id) + query = query.order_by(Deal.expected_close_date, Deal.created_at.desc()) + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 50, type=int), 100) + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"deals": [d.to_dict() for d in pagination.items], "pagination": pagination_dict}) + + +@api_v1_deals_bp.route("/deals/", methods=["GET"]) +@require_api_token("read:deals") +def get_deal(deal_id): + """Get a deal by id.""" + blocked = _require_module_enabled_for_api("deals") + if blocked: + return blocked + deal = Deal.query.filter_by(id=deal_id).first_or_404() + if not g.api_user.is_admin and deal.owner_id != g.api_user.id: + return forbidden_response("Access denied") + return jsonify({"deal": deal.to_dict()}) + + +@api_v1_deals_bp.route("/deals", methods=["POST"]) +@require_api_token("write:deals") +def create_deal(): + """Create a deal.""" + blocked = _require_module_enabled_for_api("deals") + if blocked: + return blocked + data = request.get_json() or {} + name = (data.get("name") or "").strip() + if not name: + return jsonify({"error": "name is required"}), 400 + value = None + if data.get("value") is not None: + try: + value = Decimal(str(data["value"])) + except Exception: + return error_response("Invalid value", status_code=400) + expected_close_date = _parse_date(data.get("expected_close_date")) + deal = Deal( + name=name, + created_by=g.api_user.id, + client_id=data.get("client_id"), + contact_id=data.get("contact_id"), + lead_id=data.get("lead_id"), + description=(data.get("description") or "").strip() or None, + stage=(data.get("stage") or "prospecting").strip(), + value=value, + currency_code=(data.get("currency_code") or "EUR").strip(), + probability=int(data.get("probability", 50)), + expected_close_date=expected_close_date, + status=(data.get("status") or "open").strip(), + loss_reason=(data.get("loss_reason") or "").strip() or None, + notes=(data.get("notes") or "").strip() or None, + owner_id=data.get("owner_id") or g.api_user.id, + related_quote_id=data.get("related_quote_id"), + related_project_id=data.get("related_project_id"), + ) + db.session.add(deal) + db.session.commit() + return jsonify({"message": "Deal created successfully", "deal": deal.to_dict()}), 201 + + +@api_v1_deals_bp.route("/deals/", methods=["PUT", "PATCH"]) +@require_api_token("write:deals") +def update_deal(deal_id): + """Update a deal.""" + blocked = _require_module_enabled_for_api("deals") + if blocked: + return blocked + deal = Deal.query.filter_by(id=deal_id).first_or_404() + if not g.api_user.is_admin and deal.owner_id != g.api_user.id: + return forbidden_response("Access denied") + data = request.get_json() or {} + for field in ("name", "description", "stage", "status", "loss_reason", "notes", "currency_code"): + if field in data and data[field] is not None: + setattr(deal, field, str(data[field]).strip() if isinstance(data[field], str) else data[field]) + for field in ("client_id", "contact_id", "lead_id", "probability", "related_quote_id", "related_project_id", "owner_id"): + if field in data: + setattr(deal, field, data[field]) + if "value" in data: + try: + deal.value = Decimal(str(data["value"])) if data["value"] is not None else None + except Exception: + pass + if "expected_close_date" in data: + deal.expected_close_date = _parse_date(data["expected_close_date"]) + if "actual_close_date" in data: + deal.actual_close_date = _parse_date(data["actual_close_date"]) + db.session.commit() + return jsonify({"message": "Deal updated successfully", "deal": deal.to_dict()}) + + +@api_v1_deals_bp.route("/deals/", methods=["DELETE"]) +@require_api_token("write:deals") +def delete_deal(deal_id): + """Delete (or cancel) a deal.""" + blocked = _require_module_enabled_for_api("deals") + if blocked: + return blocked + deal = Deal.query.filter_by(id=deal_id).first_or_404() + if not g.api_user.is_admin and deal.owner_id != g.api_user.id: + return forbidden_response("Access denied") + db.session.delete(deal) + db.session.commit() + return jsonify({"message": "Deal deleted successfully"}) diff --git a/app/routes/api_v1_expenses.py b/app/routes/api_v1_expenses.py new file mode 100644 index 00000000..5dca5908 --- /dev/null +++ b/app/routes/api_v1_expenses.py @@ -0,0 +1,168 @@ +""" +API v1 - Expenses sub-blueprint. +Routes under /api/v1/expenses. +""" + +from flask import Blueprint, jsonify, request, g +from decimal import Decimal +from app.utils.api_auth import require_api_token +from app.routes.api_v1_common import _parse_date + +api_v1_expenses_bp = Blueprint("api_v1_expenses", __name__, url_prefix="/api/v1") + + +@api_v1_expenses_bp.route("/expenses", methods=["GET"]) +@require_api_token("read:expenses") +def list_expenses(): + """List expenses.""" + from app.services import ExpenseService + + user_id = request.args.get("user_id", type=int) + if user_id: + if not g.api_user.is_admin and user_id != g.api_user.id: + return jsonify({"error": "Access denied"}), 403 + else: + if not g.api_user.is_admin: + user_id = g.api_user.id + project_id = request.args.get("project_id", type=int) + client_id = request.args.get("client_id", type=int) + status = request.args.get("status") + category = request.args.get("category") + start_date = _parse_date(request.args.get("start_date")) + end_date = _parse_date(request.args.get("end_date")) + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 50, type=int) + expense_service = ExpenseService() + result = expense_service.list_expenses( + user_id=user_id, + project_id=project_id, + client_id=client_id, + status=status, + category=category, + start_date=start_date, + end_date=end_date, + is_admin=g.api_user.is_admin, + page=page, + per_page=per_page, + ) + pagination = result["pagination"] + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"expenses": [e.to_dict() for e in result["expenses"]], "pagination": pagination_dict}) + + +@api_v1_expenses_bp.route("/expenses/", methods=["GET"]) +@require_api_token("read:expenses") +def get_expense(expense_id): + """Get an expense.""" + from sqlalchemy.orm import joinedload + from app.models import Expense + + expense = ( + Expense.query.options(joinedload(Expense.project), joinedload(Expense.user), joinedload(Expense.client)) + .filter_by(id=expense_id) + .first_or_404() + ) + if not g.api_user.is_admin and expense.user_id != g.api_user.id: + return jsonify({"error": "Access denied"}), 403 + return jsonify({"expense": expense.to_dict()}) + + +@api_v1_expenses_bp.route("/expenses", methods=["POST"]) +@require_api_token("write:expenses") +def create_expense(): + """Create a new expense.""" + from app.services import ExpenseService + + data = request.get_json() or {} + required = ["title", "category", "amount", "expense_date"] + missing = [f for f in required if not data.get(f)] + if missing: + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + exp_date = _parse_date(data.get("expense_date")) + if not exp_date: + return jsonify({"error": "Invalid expense_date format, expected YYYY-MM-DD"}), 400 + pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else None + try: + amount = Decimal(str(data["amount"])) + except Exception: + return jsonify({"error": "Invalid amount"}), 400 + expense_service = ExpenseService() + result = expense_service.create_expense( + amount=amount, + expense_date=exp_date, + created_by=g.api_user.id, + title=data["title"], + description=data.get("description"), + project_id=data.get("project_id"), + client_id=data.get("client_id"), + category=data["category"], + billable=data.get("billable", False), + reimbursable=data.get("reimbursable", True), + currency_code=data.get("currency_code", "EUR"), + tax_amount=Decimal(str(data.get("tax_amount", 0))) if data.get("tax_amount") else None, + tax_rate=Decimal(str(data.get("tax_rate", 0))) if data.get("tax_rate") else None, + payment_method=data.get("payment_method"), + payment_date=pay_date, + tags=data.get("tags"), + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not create expense")}), 400 + return jsonify({"message": "Expense created successfully", "expense": result["expense"].to_dict()}), 201 + + +@api_v1_expenses_bp.route("/expenses/", methods=["PUT", "PATCH"]) +@require_api_token("write:expenses") +def update_expense(expense_id): + """Update an expense.""" + from app.services import ExpenseService + + data = request.get_json() or {} + update_kwargs = {} + for field in ("title", "description", "category", "currency_code", "payment_method", "status", "tags"): + if field in data: + update_kwargs[field] = data[field] + if "amount" in data: + try: + update_kwargs["amount"] = Decimal(str(data["amount"])) + except Exception: + pass + if "expense_date" in data: + parsed = _parse_date(data["expense_date"]) + if parsed: + update_kwargs["expense_date"] = parsed + if "payment_date" in data: + update_kwargs["payment_date"] = _parse_date(data["payment_date"]) + for bfield in ("billable", "reimbursable", "reimbursed", "invoiced"): + if bfield in data: + update_kwargs[bfield] = bool(data[bfield]) + expense_service = ExpenseService() + result = expense_service.update_expense( + expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin, **update_kwargs + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not update expense")}), 400 + return jsonify({"message": "Expense updated successfully", "expense": result["expense"].to_dict()}) + + +@api_v1_expenses_bp.route("/expenses/", methods=["DELETE"]) +@require_api_token("write:expenses") +def delete_expense(expense_id): + """Reject an expense (soft-delete).""" + from app.services import ExpenseService + + expense_service = ExpenseService() + result = expense_service.delete_expense( + expense_id=expense_id, user_id=g.api_user.id, is_admin=g.api_user.is_admin + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not reject expense")}), 400 + return jsonify({"message": "Expense rejected successfully"}) diff --git a/app/routes/api_v1_invoices.py b/app/routes/api_v1_invoices.py new file mode 100644 index 00000000..b75a75ce --- /dev/null +++ b/app/routes/api_v1_invoices.py @@ -0,0 +1,146 @@ +""" +API v1 - Invoices sub-blueprint. +Routes under /api/v1/invoices. +""" + +from flask import Blueprint, jsonify, request, g, current_app +from app import db +from app.utils.api_auth import require_api_token +from app.routes.api_v1_common import _parse_date + +api_v1_invoices_bp = Blueprint("api_v1_invoices", __name__, url_prefix="/api/v1") + + +@api_v1_invoices_bp.route("/invoices", methods=["GET"]) +@require_api_token("read:invoices") +def list_invoices(): + """List invoices.""" + from app.services import InvoiceService + + status = request.args.get("status") + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 50, type=int) + invoice_service = InvoiceService() + result = invoice_service.list_invoices( + status=status, + user_id=g.api_user.id if not g.api_user.is_admin else None, + is_admin=g.api_user.is_admin, + page=page, + per_page=per_page, + ) + pagination = result["pagination"] + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"invoices": [inv.to_dict() for inv in result["invoices"]], "pagination": pagination_dict}) + + +@api_v1_invoices_bp.route("/invoices/", methods=["GET"]) +@require_api_token("read:invoices") +def get_invoice(invoice_id): + """Get invoice by id.""" + from sqlalchemy.orm import joinedload + from app.models import Invoice + + invoice = ( + Invoice.query.options(joinedload(Invoice.project), joinedload(Invoice.client)) + .filter_by(id=invoice_id) + .first_or_404() + ) + return jsonify({"invoice": invoice.to_dict()}) + + +@api_v1_invoices_bp.route("/invoices", methods=["POST"]) +@require_api_token("write:invoices") +def create_invoice(): + """Create a new invoice.""" + from app.services import InvoiceService + + data = request.get_json() or {} + required = ["project_id", "client_id", "client_name", "due_date"] + missing = [f for f in required if not data.get(f)] + if missing: + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + due_dt = _parse_date(data.get("due_date")) + if not due_dt: + return jsonify({"error": "Invalid due_date format, expected YYYY-MM-DD"}), 400 + issue_dt = None + if data.get("issue_date"): + issue_dt = _parse_date(data.get("issue_date")) + if not issue_dt: + return jsonify({"error": "Invalid issue_date format, expected YYYY-MM-DD"}), 400 + invoice_service = InvoiceService() + result = invoice_service.create_invoice( + project_id=data["project_id"], + client_id=data["client_id"], + client_name=data["client_name"], + due_date=due_dt, + created_by=g.api_user.id, + invoice_number=data.get("invoice_number"), + client_email=data.get("client_email"), + client_address=data.get("client_address"), + notes=data.get("notes"), + terms=data.get("terms"), + tax_rate=data.get("tax_rate"), + currency_code=data.get("currency_code"), + issue_date=issue_dt, + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not create invoice")}), 400 + return jsonify({"message": "Invoice created successfully", "invoice": result["invoice"].to_dict()}), 201 + + +@api_v1_invoices_bp.route("/invoices/", methods=["PUT", "PATCH"]) +@require_api_token("write:invoices") +def update_invoice(invoice_id): + """Update an invoice.""" + from app.services import InvoiceService + from decimal import Decimal, InvalidOperation + + data = request.get_json() or {} + update_kwargs = {} + for field in ("client_name", "client_email", "client_address", "notes", "terms", "status", "currency_code"): + if field in data: + update_kwargs[field] = data[field] + if "due_date" in data: + parsed = _parse_date(data["due_date"]) + if parsed: + update_kwargs["due_date"] = parsed + if "tax_rate" in data: + try: + update_kwargs["tax_rate"] = float(data["tax_rate"]) + except (ValueError, TypeError) as e: + current_app.logger.warning("Invalid tax_rate value in invoice update: %s - %s", data.get("tax_rate"), e) + if "amount_paid" in data: + try: + update_kwargs["amount_paid"] = Decimal(str(data["amount_paid"])) + except (ValueError, TypeError, InvalidOperation) as e: + current_app.logger.warning("Invalid amount_paid value in invoice update: %s - %s", data.get("amount_paid"), e) + invoice_service = InvoiceService() + result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, **update_kwargs) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not update invoice")}), 400 + if "amount_paid" in data: + result["invoice"].update_payment_status() + db.session.commit() + return jsonify({"message": "Invoice updated successfully", "invoice": result["invoice"].to_dict()}) + + +@api_v1_invoices_bp.route("/invoices/", methods=["DELETE"]) +@require_api_token("write:invoices") +def delete_invoice(invoice_id): + """Cancel an invoice (soft-delete).""" + from app.services import InvoiceService + + invoice_service = InvoiceService() + result = invoice_service.update_invoice(invoice_id=invoice_id, user_id=g.api_user.id, status="cancelled") + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not cancel invoice")}), 400 + return jsonify({"message": "Invoice cancelled successfully"}) diff --git a/app/routes/api_v1_leads.py b/app/routes/api_v1_leads.py new file mode 100644 index 00000000..3eae8ea4 --- /dev/null +++ b/app/routes/api_v1_leads.py @@ -0,0 +1,159 @@ +""" +API v1 - Leads (CRM) sub-blueprint. +Routes under /api/v1/leads. +""" + +from flask import Blueprint, jsonify, request, g +from decimal import Decimal +from sqlalchemy import or_ +from app import db +from app.models import Lead +from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, forbidden_response +from app.routes.api_v1_common import _require_module_enabled_for_api + +api_v1_leads_bp = Blueprint("api_v1_leads", __name__, url_prefix="/api/v1") + + +@api_v1_leads_bp.route("/leads", methods=["GET"]) +@require_api_token("read:leads") +def list_leads(): + """List leads with optional filters.""" + blocked = _require_module_enabled_for_api("leads") + if blocked: + return blocked + status = request.args.get("status", "") + source = request.args.get("source", "") + owner_id = request.args.get("owner", type=int) + search = (request.args.get("search") or "").strip() + query = Lead.query + if status: + query = query.filter_by(status=status) + else: + query = query.filter(~Lead.status.in_(["converted", "lost"])) + if source: + query = query.filter_by(source=source) + if owner_id and not g.api_user.is_admin: + query = query.filter_by(owner_id=g.api_user.id) + elif owner_id: + query = query.filter_by(owner_id=owner_id) + if search: + like = f"%{search}%" + query = query.filter( + or_( + Lead.first_name.ilike(like), + Lead.last_name.ilike(like), + Lead.company_name.ilike(like), + Lead.email.ilike(like), + ) + ) + query = query.order_by(Lead.score.desc(), Lead.created_at.desc()) + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 50, type=int), 100) + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"leads": [l.to_dict() for l in pagination.items], "pagination": pagination_dict}) + + +@api_v1_leads_bp.route("/leads/", methods=["GET"]) +@require_api_token("read:leads") +def get_lead(lead_id): + """Get a lead by id.""" + blocked = _require_module_enabled_for_api("leads") + if blocked: + return blocked + lead = Lead.query.filter_by(id=lead_id).first_or_404() + if not g.api_user.is_admin and lead.owner_id != g.api_user.id: + return forbidden_response("Access denied") + return jsonify({"lead": lead.to_dict()}) + + +@api_v1_leads_bp.route("/leads", methods=["POST"]) +@require_api_token("write:leads") +def create_lead(): + """Create a lead.""" + blocked = _require_module_enabled_for_api("leads") + if blocked: + return blocked + data = request.get_json() or {} + first_name = (data.get("first_name") or "").strip() + last_name = (data.get("last_name") or "").strip() + if not first_name or not last_name: + return error_response("first_name and last_name are required", status_code=400) + estimated_value = None + if data.get("estimated_value") is not None: + try: + estimated_value = Decimal(str(data["estimated_value"])) + except Exception: + pass + lead = Lead( + first_name=first_name, + last_name=last_name, + created_by=g.api_user.id, + company_name=(data.get("company_name") or "").strip() or None, + email=(data.get("email") or "").strip() or None, + phone=(data.get("phone") or "").strip() or None, + title=(data.get("title") or "").strip() or None, + source=(data.get("source") or "").strip() or None, + status=(data.get("status") or "new").strip(), + score=int(data.get("score", 0)), + estimated_value=estimated_value, + currency_code=(data.get("currency_code") or "EUR").strip(), + notes=(data.get("notes") or "").strip() or None, + tags=(data.get("tags") or "").strip() or None, + owner_id=data.get("owner_id") or g.api_user.id, + ) + db.session.add(lead) + db.session.commit() + return jsonify({"message": "Lead created successfully", "lead": lead.to_dict()}), 201 + + +@api_v1_leads_bp.route("/leads/", methods=["PUT", "PATCH"]) +@require_api_token("write:leads") +def update_lead(lead_id): + """Update a lead.""" + blocked = _require_module_enabled_for_api("leads") + if blocked: + return blocked + lead = Lead.query.filter_by(id=lead_id).first_or_404() + if not g.api_user.is_admin and lead.owner_id != g.api_user.id: + return forbidden_response("Access denied") + data = request.get_json() or {} + for field in ("first_name", "last_name", "company_name", "email", "phone", "title", "source", "status", "notes", "tags"): + if field in data and data[field] is not None: + setattr(lead, field, str(data[field]).strip() if isinstance(data[field], str) else data[field]) + if "score" in data: + lead.score = int(data["score"]) + if "estimated_value" in data: + try: + lead.estimated_value = Decimal(str(data["estimated_value"])) if data["estimated_value"] is not None else None + except Exception: + pass + if "owner_id" in data: + lead.owner_id = data["owner_id"] + db.session.commit() + return jsonify({"message": "Lead updated successfully", "lead": lead.to_dict()}) + + +@api_v1_leads_bp.route("/leads/", methods=["DELETE"]) +@require_api_token("write:leads") +def delete_lead(lead_id): + """Delete a lead.""" + blocked = _require_module_enabled_for_api("leads") + if blocked: + return blocked + lead = Lead.query.filter_by(id=lead_id).first_or_404() + if not g.api_user.is_admin and lead.owner_id != g.api_user.id: + return forbidden_response("Access denied") + db.session.delete(lead) + db.session.commit() + return jsonify({"message": "Lead deleted successfully"}) diff --git a/app/routes/api_v1_mileage.py b/app/routes/api_v1_mileage.py new file mode 100644 index 00000000..e8671d77 --- /dev/null +++ b/app/routes/api_v1_mileage.py @@ -0,0 +1,172 @@ +""" +API v1 - Mileage sub-blueprint. +Routes under /api/v1/mileage. +""" + +from flask import Blueprint, jsonify, request, g +from decimal import Decimal +from app import db +from app.models import Mileage +from app.utils.api_auth import require_api_token +from app.routes.api_v1_common import _parse_date + +api_v1_mileage_bp = Blueprint("api_v1_mileage", __name__, url_prefix="/api/v1") + + +@api_v1_mileage_bp.route("/mileage", methods=["GET"]) +@require_api_token("read:mileage") +def list_mileage(): + """List mileage entries.""" + from sqlalchemy.orm import joinedload + + user_id = request.args.get("user_id", type=int) + if user_id: + if not g.api_user.is_admin and user_id != g.api_user.id: + return jsonify({"error": "Access denied"}), 403 + else: + if not g.api_user.is_admin: + user_id = g.api_user.id + project_id = request.args.get("project_id", type=int) + start_date = _parse_date(request.args.get("start_date")) + end_date = _parse_date(request.args.get("end_date")) + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 50, type=int) + query = Mileage.query.options( + joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client) + ) + if user_id: + query = query.filter(Mileage.user_id == user_id) + if project_id: + query = query.filter(Mileage.project_id == project_id) + if start_date: + query = query.filter(Mileage.trip_date >= start_date) + if end_date: + query = query.filter(Mileage.trip_date <= end_date) + query = query.order_by(Mileage.trip_date.desc(), Mileage.created_at.desc()) + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"mileage": [m.to_dict() for m in pagination.items], "pagination": pagination_dict}) + + +@api_v1_mileage_bp.route("/mileage/", methods=["GET"]) +@require_api_token("read:mileage") +def get_mileage(entry_id): + """Get a mileage entry.""" + from sqlalchemy.orm import joinedload + + entry = ( + Mileage.query.options( + joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client) + ) + .filter_by(id=entry_id) + .first_or_404() + ) + if not g.api_user.is_admin and entry.user_id != g.api_user.id: + return jsonify({"error": "Access denied"}), 403 + return jsonify({"mileage": entry.to_dict()}) + + +@api_v1_mileage_bp.route("/mileage", methods=["POST"]) +@require_api_token("write:mileage") +def create_mileage(): + """Create a mileage entry.""" + data = request.get_json() or {} + required = ["trip_date", "purpose", "start_location", "end_location", "distance_km", "rate_per_km"] + missing = [f for f in required if not data.get(f)] + if missing: + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + trip_date = _parse_date(data.get("trip_date")) + if not trip_date: + return jsonify({"error": "Invalid trip_date format, expected YYYY-MM-DD"}), 400 + try: + distance_km = Decimal(str(data["distance_km"])) + rate_per_km = Decimal(str(data["rate_per_km"])) + except Exception: + return jsonify({"error": "Invalid distance_km or rate_per_km"}), 400 + entry = Mileage( + user_id=g.api_user.id, + trip_date=trip_date, + purpose=data["purpose"], + start_location=data["start_location"], + end_location=data["end_location"], + distance_km=distance_km, + rate_per_km=rate_per_km, + project_id=data.get("project_id"), + client_id=data.get("client_id"), + is_round_trip=bool(data.get("is_round_trip", False)), + description=data.get("description"), + ) + db.session.add(entry) + db.session.commit() + return jsonify({"message": "Mileage entry created successfully", "mileage": entry.to_dict()}), 201 + + +@api_v1_mileage_bp.route("/mileage/", methods=["PUT", "PATCH"]) +@require_api_token("write:mileage") +def update_mileage(entry_id): + """Update a mileage entry.""" + from sqlalchemy.orm import joinedload + + entry = ( + Mileage.query.options( + joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client) + ) + .filter_by(id=entry_id) + .first_or_404() + ) + if not g.api_user.is_admin and entry.user_id != g.api_user.id: + return jsonify({"error": "Access denied"}), 403 + data = request.get_json() or {} + for field in ( + "purpose", "start_location", "end_location", "description", + "vehicle_type", "vehicle_description", "license_plate", "currency_code", "status", "notes", + ): + if field in data: + setattr(entry, field, data[field]) + if "trip_date" in data: + parsed = _parse_date(data["trip_date"]) + if parsed: + entry.trip_date = parsed + for numfield in ("distance_km", "rate_per_km", "start_odometer", "end_odometer"): + if numfield in data: + try: + setattr(entry, numfield, Decimal(str(data[numfield]))) + except Exception: + pass + if "is_round_trip" in data: + entry.is_round_trip = bool(data["is_round_trip"]) + if "distance_km" in data or "rate_per_km" in data: + entry.calculated_amount = entry.distance_km * entry.rate_per_km + if entry.is_round_trip: + entry.calculated_amount *= Decimal("2") + db.session.commit() + return jsonify({"message": "Mileage entry updated successfully", "mileage": entry.to_dict()}) + + +@api_v1_mileage_bp.route("/mileage/", methods=["DELETE"]) +@require_api_token("write:mileage") +def delete_mileage(entry_id): + """Reject a mileage entry.""" + from sqlalchemy.orm import joinedload + + entry = ( + Mileage.query.options( + joinedload(Mileage.user), joinedload(Mileage.project), joinedload(Mileage.client) + ) + .filter_by(id=entry_id) + .first_or_404() + ) + if not g.api_user.is_admin and entry.user_id != g.api_user.id: + return jsonify({"error": "Access denied"}), 403 + entry.status = "rejected" + db.session.commit() + return jsonify({"message": "Mileage entry rejected successfully"}) diff --git a/app/routes/api_v1_payments.py b/app/routes/api_v1_payments.py new file mode 100644 index 00000000..0a0e242d --- /dev/null +++ b/app/routes/api_v1_payments.py @@ -0,0 +1,124 @@ +""" +API v1 - Payments sub-blueprint. +Routes under /api/v1/payments. +""" + +from flask import Blueprint, jsonify, request, g +from decimal import Decimal +from app.utils.api_auth import require_api_token +from app.routes.api_v1_common import _parse_date + +api_v1_payments_bp = Blueprint("api_v1_payments", __name__, url_prefix="/api/v1") + + +@api_v1_payments_bp.route("/payments", methods=["GET"]) +@require_api_token("read:payments") +def list_payments(): + """List payments.""" + from sqlalchemy.orm import joinedload + from app.models import Payment + + invoice_id = request.args.get("invoice_id", type=int) + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 50, type=int) + query = Payment.query.options(joinedload(Payment.invoice)) + if invoice_id: + query = query.filter(Payment.invoice_id == invoice_id) + query = query.order_by(Payment.created_at.desc()) + pagination = query.paginate(page=page, per_page=per_page, error_out=False) + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"payments": [p.to_dict() for p in pagination.items], "pagination": pagination_dict}) + + +@api_v1_payments_bp.route("/payments/", methods=["GET"]) +@require_api_token("read:payments") +def get_payment(payment_id): + """Get a payment.""" + from sqlalchemy.orm import joinedload + from app.models import Payment + + payment = Payment.query.options(joinedload(Payment.invoice)).filter_by(id=payment_id).first_or_404() + return jsonify({"payment": payment.to_dict()}) + + +@api_v1_payments_bp.route("/payments", methods=["POST"]) +@require_api_token("write:payments") +def create_payment(): + """Create a payment.""" + from app.services import PaymentService + from datetime import date + + data = request.get_json() or {} + required = ["invoice_id", "amount"] + missing = [f for f in required if not data.get(f)] + if missing: + return jsonify({"error": f"Missing required fields: {', '.join(missing)}"}), 400 + try: + amount = Decimal(str(data["amount"])) + except Exception: + return jsonify({"error": "Invalid amount"}), 400 + pay_date = _parse_date(data.get("payment_date")) if data.get("payment_date") else date.today() + payment_service = PaymentService() + result = payment_service.create_payment( + invoice_id=data["invoice_id"], + amount=amount, + payment_date=pay_date, + received_by=g.api_user.id, + currency=data.get("currency"), + method=data.get("method"), + reference=data.get("reference"), + notes=data.get("notes"), + status=data.get("status", "completed"), + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not create payment")}), 400 + return jsonify({"message": "Payment created successfully", "payment": result["payment"].to_dict()}), 201 + + +@api_v1_payments_bp.route("/payments/", methods=["PUT", "PATCH"]) +@require_api_token("write:payments") +def update_payment(payment_id): + """Update a payment.""" + from app.services import PaymentService + + data = request.get_json() or {} + update_kwargs = {} + for field in ("currency", "method", "reference", "notes", "status"): + if field in data: + update_kwargs[field] = data[field] + if "amount" in data: + try: + update_kwargs["amount"] = Decimal(str(data["amount"])) + except Exception: + pass + if "payment_date" in data: + parsed = _parse_date(data["payment_date"]) + if parsed: + update_kwargs["payment_date"] = parsed + payment_service = PaymentService() + result = payment_service.update_payment(payment_id=payment_id, user_id=g.api_user.id, **update_kwargs) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not update payment")}), 400 + return jsonify({"message": "Payment updated successfully", "payment": result["payment"].to_dict()}) + + +@api_v1_payments_bp.route("/payments/", methods=["DELETE"]) +@require_api_token("write:payments") +def delete_payment(payment_id): + """Delete a payment.""" + from app.services import PaymentService + + payment_service = PaymentService() + result = payment_service.delete_payment(payment_id=payment_id, user_id=g.api_user.id) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not delete payment")}), 400 + return jsonify({"message": "Payment deleted successfully"}) diff --git a/app/routes/api_v1_projects.py b/app/routes/api_v1_projects.py new file mode 100644 index 00000000..c2044f78 --- /dev/null +++ b/app/routes/api_v1_projects.py @@ -0,0 +1,146 @@ +""" +API v1 - Projects sub-blueprint. +Routes under /api/v1/projects. +""" + +from flask import Blueprint, jsonify, request, g +from app.utils.api_auth import require_api_token +from app.utils.api_responses import error_response, not_found_response + +api_v1_projects_bp = Blueprint("api_v1_projects", __name__, url_prefix="/api/v1") + + +@api_v1_projects_bp.route("/projects", methods=["GET"]) +@require_api_token("read:projects") +def list_projects(): + """List all projects.""" + from app.services import ProjectService + from app.utils.scope_filter import get_allowed_client_ids + + status = request.args.get("status", "active") + client_id = request.args.get("client_id", type=int) + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 20, type=int) + scope_client_ids = get_allowed_client_ids(g.api_user) + + project_service = ProjectService() + result = project_service.list_projects( + status=status, + client_id=client_id, + page=page, + per_page=per_page, + scope_client_ids=scope_client_ids, + ) + pag = result["pagination"] + pagination_dict = { + "page": pag.page, + "per_page": pag.per_page, + "total": pag.total, + "pages": pag.pages, + "has_next": pag.has_next, + "has_prev": pag.has_prev, + "next_page": pag.page + 1 if pag.has_next else None, + "prev_page": pag.page - 1 if pag.has_prev else None, + } + return jsonify({"projects": [p.to_dict() for p in result["projects"]], "pagination": pagination_dict}) + + +@api_v1_projects_bp.route("/projects/", methods=["GET"]) +@require_api_token("read:projects") +def get_project(project_id): + """Get a specific project.""" + from app.services import ProjectService + from app.utils.scope_filter import user_can_access_project + + project_service = ProjectService() + result = project_service.get_project_with_details(project_id=project_id, include_time_entries=False) + + if not result: + return not_found_response("Project", project_id) + if not user_can_access_project(g.api_user, project_id): + return jsonify({"error": "Access denied", "message": "You do not have access to this project"}), 403 + + return jsonify({"project": result.to_dict()}) + + +@api_v1_projects_bp.route("/projects", methods=["POST"]) +@require_api_token("write:projects") +def create_project(): + """Create a new project.""" + from app.services import ProjectService + + data = request.get_json() or {} + if not data.get("name"): + return jsonify({"error": "Project name is required"}), 400 + + project_service = ProjectService() + result = project_service.create_project( + name=data["name"], + client_id=data.get("client_id"), + created_by=g.api_user.id, + description=data.get("description"), + billable=data.get("billable", True), + hourly_rate=data.get("hourly_rate"), + code=data.get("code"), + budget_amount=data.get("budget_amount"), + budget_threshold_percent=data.get("budget_threshold_percent"), + billing_ref=data.get("billing_ref"), + ) + + if not result.get("success"): + return error_response(result.get("message", "Could not create project"), status_code=400) + + return jsonify({"message": "Project created successfully", "project": result["project"].to_dict()}), 201 + + +@api_v1_projects_bp.route("/projects/", methods=["PUT", "PATCH"]) +@require_api_token("write:projects") +def update_project(project_id): + """Update a project.""" + from app.services import ProjectService + + data = request.get_json() or {} + project_service = ProjectService() + update_kwargs = {} + if "name" in data: + update_kwargs["name"] = data["name"] + if "description" in data: + update_kwargs["description"] = data["description"] + if "client_id" in data: + update_kwargs["client_id"] = data["client_id"] + if "hourly_rate" in data: + update_kwargs["hourly_rate"] = data["hourly_rate"] + if "estimated_hours" in data: + update_kwargs["estimated_hours"] = data["estimated_hours"] + if "status" in data: + update_kwargs["status"] = data["status"] + if "code" in data: + update_kwargs["code"] = data["code"] + if "budget_amount" in data: + update_kwargs["budget_amount"] = data["budget_amount"] + if "billing_ref" in data: + update_kwargs["billing_ref"] = data["billing_ref"] + + result = project_service.update_project(project_id=project_id, user_id=g.api_user.id, **update_kwargs) + + if not result.get("success"): + return error_response(result.get("message", "Could not update project"), status_code=400) + + return jsonify({"message": "Project updated successfully", "project": result["project"].to_dict()}) + + +@api_v1_projects_bp.route("/projects/", methods=["DELETE"]) +@require_api_token("write:projects") +def delete_project(project_id): + """Delete/archive a project.""" + from app.services import ProjectService + + project_service = ProjectService() + result = project_service.archive_project( + project_id=project_id, user_id=g.api_user.id, reason="Archived via API" + ) + + if not result.get("success"): + return error_response(result.get("message", "Could not archive project"), status_code=404) + + return jsonify({"message": "Project archived successfully"}) diff --git a/app/routes/api_v1_tasks.py b/app/routes/api_v1_tasks.py new file mode 100644 index 00000000..34cbcbff --- /dev/null +++ b/app/routes/api_v1_tasks.py @@ -0,0 +1,137 @@ +""" +API v1 - Tasks sub-blueprint. +Routes under /api/v1/tasks. +""" + +from flask import Blueprint, jsonify, request, g +from app import db +from app.utils.api_auth import require_api_token + +api_v1_tasks_bp = Blueprint("api_v1_tasks", __name__, url_prefix="/api/v1") + + +@api_v1_tasks_bp.route("/tasks", methods=["GET"]) +@require_api_token("read:tasks") +def list_tasks(): + """List tasks.""" + from app.services import TaskService + + project_id = request.args.get("project_id", type=int) + status = request.args.get("status") + tags = request.args.get("tags", "").strip() or None + page = request.args.get("page", 1, type=int) + per_page = request.args.get("per_page", 50, type=int) + + task_service = TaskService() + result = task_service.list_tasks( + project_id=project_id, + status=status, + tags=tags, + page=page, + per_page=per_page, + ) + pagination = result["pagination"] + pagination_dict = { + "page": pagination.page, + "per_page": pagination.per_page, + "total": pagination.total, + "pages": pagination.pages, + "has_next": pagination.has_next, + "has_prev": pagination.has_prev, + "next_page": pagination.page + 1 if pagination.has_next else None, + "prev_page": pagination.page - 1 if pagination.has_prev else None, + } + return jsonify({"tasks": [t.to_dict() for t in result["tasks"]], "pagination": pagination_dict}) + + +@api_v1_tasks_bp.route("/tasks/", methods=["GET"]) +@require_api_token("read:tasks") +def get_task(task_id): + """Get a specific task.""" + from sqlalchemy.orm import joinedload + from app.models import Task + + task = ( + Task.query.options( + joinedload(Task.project), joinedload(Task.assignee), joinedload(Task.created_by_user) + ) + .filter_by(id=task_id) + .first_or_404() + ) + return jsonify({"task": task.to_dict()}) + + +@api_v1_tasks_bp.route("/tasks", methods=["POST"]) +@require_api_token("write:tasks") +def create_task(): + """Create a new task.""" + from app.services import TaskService + + data = request.get_json() or {} + if not data.get("name"): + return jsonify({"error": "Task name is required"}), 400 + if not data.get("project_id"): + return jsonify({"error": "project_id is required"}), 400 + + task_service = TaskService() + result = task_service.create_task( + name=data["name"], + project_id=data["project_id"], + created_by=g.api_user.id, + description=data.get("description"), + assignee_id=data.get("assignee_id"), + priority=data.get("priority", "medium"), + due_date=data.get("due_date"), + estimated_hours=data.get("estimated_hours"), + tags=data.get("tags"), + ) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not create task")}), 400 + return jsonify({"message": "Task created successfully", "task": result["task"].to_dict()}), 201 + + +@api_v1_tasks_bp.route("/tasks/", methods=["PUT", "PATCH"]) +@require_api_token("write:tasks") +def update_task(task_id): + """Update a task.""" + from app.services import TaskService + + data = request.get_json() or {} + task_service = TaskService() + update_kwargs = {} + if "name" in data: + update_kwargs["name"] = data["name"] + if "description" in data: + update_kwargs["description"] = data["description"] + if "status" in data: + update_kwargs["status"] = data["status"] + if "priority" in data: + update_kwargs["priority"] = data["priority"] + if "assignee_id" in data: + update_kwargs["assignee_id"] = data["assignee_id"] + if "due_date" in data: + update_kwargs["due_date"] = data["due_date"] + if "estimated_hours" in data: + update_kwargs["estimated_hours"] = data["estimated_hours"] + if "tags" in data: + update_kwargs["tags"] = data["tags"] + + result = task_service.update_task(task_id=task_id, user_id=g.api_user.id, **update_kwargs) + if not result.get("success"): + return jsonify({"error": result.get("message", "Could not update task")}), 400 + return jsonify({"message": "Task updated successfully", "task": result["task"].to_dict()}) + + +@api_v1_tasks_bp.route("/tasks/", methods=["DELETE"]) +@require_api_token("write:tasks") +def delete_task(task_id): + """Delete a task.""" + from app.repositories import TaskRepository + + task_repo = TaskRepository() + task = task_repo.get_by_id(task_id) + if not task: + return jsonify({"error": "Task not found"}), 404 + db.session.delete(task) + db.session.commit() + return jsonify({"message": "Task deleted successfully"}) diff --git a/app/routes/main.py b/app/routes/main.py index 4340930d..ca189499 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -49,69 +49,29 @@ def dashboard(): only_one_client = len(active_clients) == 1 single_client = active_clients[0] if only_one_client else None - # Get user statistics using analytics service + # Get user statistics and dashboard aggregations via analytics service from app.services import AnalyticsService + from app.utils.overtime import calculate_period_overtime, get_week_start_for_date analytics_service = AnalyticsService() stats = analytics_service.get_dashboard_stats(user_id=current_user.id) - today_hours = stats["time_tracking"]["today_hours"] week_hours = stats["time_tracking"]["week_hours"] month_hours = stats["time_tracking"]["month_hours"] # Overtime for dashboard cards (today and week) - from app.utils.overtime import calculate_period_overtime, get_week_start_for_date today_dt = datetime.utcnow().date() week_start_dt = get_week_start_for_date(today_dt, current_user) today_overtime = calculate_period_overtime(current_user, today_dt, today_dt) week_overtime = calculate_period_overtime(current_user, week_start_dt, today_dt) standard_hours_per_day = float(getattr(current_user, "standard_hours_per_day", 8.0) or 8.0) - # Build Top Projects (last 30 days) - using optimized query with eager loading - from sqlalchemy.orm import joinedload - - period_start = datetime.utcnow().date() - timedelta(days=30) - entries_30 = ( - TimeEntry.query.options(joinedload(TimeEntry.project)) # Eager load projects to avoid N+1 - .filter( - TimeEntry.end_time.isnot(None), TimeEntry.start_time >= period_start, TimeEntry.user_id == current_user.id - ) - .all() - ) - project_hours = {} - for e in entries_30: - if not e.project: - continue - project_hours.setdefault(e.project.id, {"project": e.project, "hours": 0.0, "billable_hours": 0.0}) - project_hours[e.project.id]["hours"] += e.duration_hours - if e.billable and e.project.billable: - project_hours[e.project.id]["billable_hours"] += e.duration_hours - top_projects = sorted(project_hours.values(), key=lambda x: x["hours"], reverse=True)[:5] - - # Time by project (last 7 days) for dashboard chart - period_7d_start = datetime.utcnow().date() - timedelta(days=7) - entries_7d = ( - TimeEntry.query.options(joinedload(TimeEntry.project)) - .filter( - TimeEntry.end_time.isnot(None), - TimeEntry.start_time >= period_7d_start, - TimeEntry.user_id == current_user.id, - ) - .all() - ) - project_hours_7d = {} - for e in entries_7d: - if not e.project: - continue - project_hours_7d.setdefault(e.project.id, {"name": e.project.name, "hours": 0.0}) - project_hours_7d[e.project.id]["hours"] += e.duration_hours - time_by_project_7d = sorted( - [{"label": v["name"], "hours": round(v["hours"], 2)} for v in project_hours_7d.values()], - key=lambda x: x["hours"], - reverse=True, - )[:10] # Top 10 for chart - chart_labels_7d = [x["label"] for x in time_by_project_7d] - chart_hours_7d = [x["hours"] for x in time_by_project_7d] + # Top projects (last 30 days) and time-by-project chart (last 7 days) from service + top_projects = analytics_service.get_dashboard_top_projects(current_user.id, days=30, limit=5) + chart_data = analytics_service.get_time_by_project_chart(current_user.id, days=7, limit=10) + time_by_project_7d = chart_data["series"] + chart_labels_7d = chart_data["chart_labels"] + chart_hours_7d = chart_data["chart_hours"] # Get current week goal current_week_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id) diff --git a/app/services/analytics_service.py b/app/services/analytics_service.py index b39414a8..a718893b 100644 --- a/app/services/analytics_service.py +++ b/app/services/analytics_service.py @@ -5,6 +5,8 @@ Service for analytics and insights business logic. from typing import Dict, Any, List, Optional from datetime import datetime, timedelta from decimal import Decimal +from sqlalchemy.orm import joinedload +from app.models import TimeEntry from app.repositories import TimeEntryRepository, ProjectRepository, InvoiceRepository, ExpenseRepository @@ -66,6 +68,75 @@ class AnalyticsService: }, } + def get_dashboard_top_projects( + self, user_id: int, days: int = 30, limit: int = 5 + ) -> List[Dict[str, Any]]: + """ + Get top projects by hours for the dashboard (single query + in-memory aggregation). + + Returns: + List of dicts with keys: project, hours, billable_hours (sorted by hours desc, limited). + """ + period_start = datetime.utcnow().date() - timedelta(days=days) + entries = ( + TimeEntry.query.options(joinedload(TimeEntry.project)) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= period_start, + TimeEntry.user_id == user_id, + ) + .all() + ) + project_hours = {} + for e in entries: + if not e.project: + continue + key = e.project.id + if key not in project_hours: + project_hours[key] = {"project": e.project, "hours": 0.0, "billable_hours": 0.0} + project_hours[key]["hours"] += e.duration_hours + if e.billable and e.project.billable: + project_hours[key]["billable_hours"] += e.duration_hours + return sorted(project_hours.values(), key=lambda x: x["hours"], reverse=True)[:limit] + + def get_time_by_project_chart( + self, user_id: int, days: int = 7, limit: int = 10 + ) -> Dict[str, Any]: + """ + Get time-by-project series for dashboard chart (single query + aggregation). + + Returns: + dict with keys: series (list of {label, hours}), chart_labels, chart_hours. + """ + period_start = datetime.utcnow().date() - timedelta(days=days) + entries = ( + TimeEntry.query.options(joinedload(TimeEntry.project)) + .filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= period_start, + TimeEntry.user_id == user_id, + ) + .all() + ) + project_hours = {} + for e in entries: + if not e.project: + continue + key = e.project.id + if key not in project_hours: + project_hours[key] = {"name": e.project.name, "hours": 0.0} + project_hours[key]["hours"] += e.duration_hours + series = sorted( + [{"label": v["name"], "hours": round(v["hours"], 2)} for v in project_hours.values()], + key=lambda x: x["hours"], + reverse=True, + )[:limit] + return { + "series": series, + "chart_labels": [x["label"] for x in series], + "chart_hours": [x["hours"] for x in series], + } + def get_trends(self, user_id: Optional[int] = None, days: int = 30) -> Dict[str, Any]: """ Get time tracking trends. diff --git a/app/utils/legacy_migrations.py b/app/utils/legacy_migrations.py new file mode 100644 index 00000000..6be9fc40 --- /dev/null +++ b/app/utils/legacy_migrations.py @@ -0,0 +1,70 @@ +""" +Legacy migration helpers (task management and issues tables). +Extracted from app/__init__.py. Prefer Flask-Migrate/Alembic for new schema changes. +""" + + +def migrate_task_management_tables(): + """Check and migrate Task Management tables if they don't exist.""" + from app import db + + try: + from sqlalchemy import inspect, text + + inspector = inspect(db.engine) + existing_tables = inspector.get_table_names() + + if "tasks" not in existing_tables: + print("Task Management: Creating tasks table...") + db.create_all() + print("✓ Tasks table created successfully") + else: + print("Task Management: Tasks table already exists") + + if "time_entries" in existing_tables: + time_entries_columns = [col["name"] for col in inspector.get_columns("time_entries")] + if "task_id" not in time_entries_columns: + print("Task Management: Adding task_id column to time_entries table...") + try: + with db.engine.begin() as conn: + conn.execute( + text("ALTER TABLE time_entries ADD COLUMN task_id INTEGER REFERENCES tasks(id)") + ) + print("✓ task_id column added to time_entries table") + except Exception as e: + print(f"⚠ Warning: Could not add task_id column: {e}") + print(" You may need to manually add this column or recreate the database") + else: + print("Task Management: task_id column already exists in time_entries table") + + print("Task Management migration check completed") + + except Exception as e: + print(f"⚠ Warning: Task Management migration check failed: {e}") + print(" The application will continue, but Task Management features may not work properly") + + +def migrate_issues_table(): + """Check and migrate Issues table if it doesn't exist.""" + from app import db + + try: + from sqlalchemy import inspect + + inspector = inspect(db.engine) + existing_tables = inspector.get_table_names() + + if "issues" not in existing_tables: + print("Issues: Creating issues table...") + from app.models import Issue + + Issue.__table__.create(db.engine, checkfirst=True) + print("✓ Issues table created successfully") + else: + print("Issues: Issues table already exists") + + print("Issues migration check completed") + + except Exception as e: + print(f"⚠ Warning: Issues migration check failed: {e}") + print(" The application will continue, but Issues features may not work properly") diff --git a/app/utils/setup_logging.py b/app/utils/setup_logging.py new file mode 100644 index 00000000..d238f4bd --- /dev/null +++ b/app/utils/setup_logging.py @@ -0,0 +1,84 @@ +""" +Application logging setup. +Extracted from app/__init__.py for clearer separation of concerns. +""" + +import os +import logging +from flask import Flask + + +def setup_logging(app: Flask) -> None: + """Setup application logging including JSON logging.""" + from pythonjsonlogger import jsonlogger + + log_level = os.getenv("LOG_LEVEL", "INFO") + default_log_path = os.path.abspath( + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs", "timetracker.log") + ) + log_file = os.getenv("LOG_FILE", default_log_path) + + json_log_path = os.path.abspath( + os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "logs", "app.jsonl") + ) + + handlers = [logging.StreamHandler()] + + try: + log_dir = os.path.dirname(log_file) + if log_dir and not os.path.exists(log_dir): + os.makedirs(log_dir, exist_ok=True) + + from logging.handlers import RotatingFileHandler + + file_handler = RotatingFileHandler( + log_file, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" + ) + handlers.append(file_handler) + except (PermissionError, OSError) as e: + print(f"Warning: Could not create log file '{log_file}': {e}") + print("Logging to console only") + + for handler in handlers: + handler.setLevel(getattr(logging, log_level.upper())) + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]") + ) + + app.logger.handlers.clear() + app.logger.propagate = False + app.logger.setLevel(getattr(logging, log_level.upper())) + for handler in handlers: + app.logger.addHandler(handler) + + root_logger = logging.getLogger() + root_logger.setLevel(getattr(logging, log_level.upper())) + root_logger.handlers = [] + for handler in handlers: + root_logger.addHandler(handler) + + try: + json_log_dir = os.path.dirname(json_log_path) + if json_log_dir and not os.path.exists(json_log_dir): + os.makedirs(json_log_dir, exist_ok=True) + + from logging.handlers import RotatingFileHandler as _RotatingFileHandler + + json_handler = _RotatingFileHandler( + json_log_path, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" + ) + json_formatter = jsonlogger.JsonFormatter("%(asctime)s %(levelname)s %(name)s %(message)s") + json_handler.setFormatter(json_formatter) + json_handler.setLevel(logging.INFO) + + json_logger = logging.getLogger("timetracker") + json_logger.handlers.clear() + json_logger.addHandler(json_handler) + json_logger.propagate = False + + app.logger.info("JSON logging initialized: %s", json_log_path) + except (PermissionError, OSError) as e: + app.logger.warning("Could not initialize JSON logging: %s", e) + + if not app.debug: + logging.getLogger("werkzeug").setLevel(logging.ERROR)