mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
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:
+17
-5
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"):
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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"})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user