refactor: split API v1 into sub-blueprints, slim bootstrap, move dashboard to AnalyticsService

Architecture and maintainability improvements per production-readiness plan:

- API v1: Split monolithic api_v1.py into per-resource blueprints
  (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). Register all in blueprint_registry;
  keep info, health, auth and remaining routes in api_v1.py.

- Bootstrap: Move setup_logging to app/utils/setup_logging.py and
  legacy migrations (task management, issues tables) to
  app/utils/legacy_migrations.py. Use SQLAlchemy 2-compatible
  db.engine.begin() in legacy_migrations.

- Dashboard: Add AnalyticsService.get_dashboard_top_projects and
  get_time_by_project_chart; thin main dashboard route to call
  services only and remove inline TimeEntry aggregation.

- Docs: Update ARCHITECTURE.md (module table, API structure, data
  flow, design decisions), DEVELOPMENT.md (workflow, build steps,
  test examples), CHANGELOG.md (Unreleased refactor entry).
This commit is contained in:
Dries Peeters
2026-03-11 11:54:04 +01:00
parent 1768ab8839
commit 0a4a1535c1
20 changed files with 1715 additions and 2275 deletions
+17 -5
View File
@@ -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 <token>` or `X-API-Key: <token>`. 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.
+3
View File
@@ -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.
+16
View File
@@ -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).
+5 -151
View File
@@ -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"):
+20
View File
@@ -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)
+3 -2071
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -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/<int:client_id>", 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
+125
View File
@@ -0,0 +1,125 @@
"""
API v1 - Contacts (CRM) sub-blueprint.
Routes under /api/v1/clients/<id>/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/<int:client_id>/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/<int:client_id>/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/<int:contact_id>", 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/<int:contact_id>", 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/<int:contact_id>", 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"})
+154
View File
@@ -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/<int:deal_id>", 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/<int:deal_id>", 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/<int:deal_id>", 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"})
+168
View File
@@ -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/<int:expense_id>", 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/<int:expense_id>", 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/<int:expense_id>", 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"})
+146
View File
@@ -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/<int:invoice_id>", 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/<int:invoice_id>", 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/<int:invoice_id>", 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"})
+159
View File
@@ -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/<int:lead_id>", 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/<int:lead_id>", 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/<int:lead_id>", 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"})
+172
View File
@@ -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/<int:entry_id>", 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/<int:entry_id>", 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/<int:entry_id>", 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"})
+124
View File
@@ -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/<int:payment_id>", 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/<int:payment_id>", 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/<int:payment_id>", 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"})
+146
View File
@@ -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/<int:project_id>", 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/<int:project_id>", 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/<int:project_id>", 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"})
+137
View File
@@ -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/<int:task_id>", 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/<int:task_id>", 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/<int:task_id>", 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"})
+8 -48
View File
@@ -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)
+71
View File
@@ -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.
+70
View File
@@ -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")
+84
View File
@@ -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)