feat: API v1 CRM/approvals, api_responses, templates, version & RBAC docs

- REST API v1: add deals, leads, contacts, time-entry-approvals (CRUD + approve/reject/cancel/bulk-approve). New scopes and /info entries.
- Standardize API errors: use error_response, forbidden_response, not_found_response in api_v1 (projects + new CRM/approval routes).
- Consolidate templates: move root templates/ into app/templates/, remove ChoiceLoader and legacy root files.
- Version: README/FEATURES_COMPLETE/CHANGELOG/mobile docs reference setup.py as single source (4.19.0); add [4.19.0] changelog entry.
- Docs: SERVICE_LAYER_AND_BASE_CRUD.md, RBAC_PERMISSION_MODEL.md; base_crud_service docstring points to service-layer doc.
- Mark projects_refactored_example, timer_refactored, invoices_refactored as REFERENCE ONLY in docstrings.
This commit is contained in:
Dries Peeters
2026-02-13 21:43:09 +01:00
parent 115db52b2b
commit e68f231b91
22 changed files with 673 additions and 129 deletions
+12
View File
@@ -10,6 +10,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Additional features and improvements in development
## [4.19.0] - 2025-02-13
### Added
- **REST API v1** - CRM and time approvals: `/api/v1/deals`, `/api/v1/leads`, `/api/v1/clients/<id>/contacts`, `/api/v1/contacts/<id>`, `/api/v1/time-entry-approvals` (list, get, approve, reject, cancel, request-approval, bulk-approve). New API token scopes: `read:deals`, `write:deals`, `read:leads`, `write:leads`, `read:contacts`, `write:contacts`, `read:time_approvals`, `write:time_approvals`.
- **Documentation** - Service layer and BaseCRUD pattern ([docs/development/SERVICE_LAYER_AND_BASE_CRUD.md](docs/development/SERVICE_LAYER_AND_BASE_CRUD.md)); RBAC permission model ([docs/development/RBAC_PERMISSION_MODEL.md](docs/development/RBAC_PERMISSION_MODEL.md)).
### Changed
- **API responses** - Projects and new CRM/approvals API v1 routes use standardized `error_response` / `forbidden_response` / `not_found_response` from `app.utils.api_responses`.
- **Templates** - All templates consolidated under `app/templates/`; root `templates/` removed and extra Jinja loader removed.
- **Version** - README, FEATURES_COMPLETE.md, and docs reference `setup.py` as single source of truth for version (4.19.0).
- **Refactored examples** - `projects_refactored_example.py`, `timer_refactored.py`, `invoices_refactored.py` marked as reference-only in module docstrings.
## [4.14.0] - 2025-01-27
### Changed
+1 -1
View File
@@ -78,7 +78,7 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh
> **📋 For complete release history, see [CHANGELOG.md](CHANGELOG.md)**
**Latest Release: v4.17.0** (February 2025)
**Latest Release: v4.19.0** (February 2025). Version is defined in `setup.py` (single source of truth).
- 📱 **Native Mobile & Desktop Apps** — Flutter mobile app (iOS/Android) and Electron desktop app with time tracking, offline support, and API integration ([Build Guide](BUILD.md), [Docs](docs/mobile-desktop-apps/README.md))
- 📋 **Project Analysis & Documentation** — Comprehensive project analysis and documentation updates
- 🔧 **Version Consistency** — Fixed version inconsistencies across documentation files
+2 -5
View File
@@ -16,7 +16,6 @@ from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from authlib.integrations.flask_client import OAuth
import re
from jinja2 import ChoiceLoader, FileSystemLoader
from urllib.parse import urlparse
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.http import parse_options_header
@@ -270,11 +269,9 @@ def create_app(config=None):
# Do not fail app creation for engine option tweaks
pass
# Add top-level templates directory in addition to app/templates
extra_templates_path = os.path.abspath(os.path.join(app.root_path, "..", "templates"))
app.jinja_loader = ChoiceLoader([app.jinja_loader, FileSystemLoader(extra_templates_path)])
# All templates live in app/templates (legacy root templates/ was merged in)
# Prefer Postgres if POSTGRES_* envs are present but URL points to SQLite
# Prefer Postgres if POSTGRES_* env vars are present but URL points to SQLite
# BUT only if DATABASE_URL was not explicitly set to SQLite
current_url = app.config.get("SQLALCHEMY_DATABASE_URI", "")
explicit_database_url = os.getenv("DATABASE_URL", "")
+522 -4
View File
@@ -44,8 +44,19 @@ from app.models import (
Supplier,
PurchaseOrder,
ApiToken,
Deal,
Lead,
Contact,
)
from app.models.time_entry_approval import TimeEntryApproval, ApprovalStatus
from app.utils.api_auth import require_api_token
from app.utils.api_responses import (
success_response,
error_response,
paginated_response,
forbidden_response,
not_found_response,
)
from datetime import datetime, timedelta
from sqlalchemy import func, or_
from app.utils.timezone import get_app_timezone, parse_local_datetime, utc_to_local
@@ -192,6 +203,10 @@ def api_info():
"expenses": "/api/v1/expenses",
"payments": "/api/v1/payments",
"mileage": "/api/v1/mileage",
"deals": "/api/v1/deals",
"leads": "/api/v1/leads",
"contacts": "/api/v1/clients/<client_id>/contacts",
"time_entry_approvals": "/api/v1/time-entry-approvals",
"per_diems": "/api/v1/per-diems",
"per_diem_rates": "/api/v1/per-diem-rates",
"budget_alerts": "/api/v1/budget-alerts",
@@ -369,7 +384,7 @@ def get_project(project_id):
result = project_service.get_project_with_details(project_id=project_id, include_time_entries=False)
if not result:
return jsonify({"error": "Project not found"}), 404
return not_found_response("Project", project_id)
return jsonify({"project": result.to_dict()})
@@ -435,7 +450,7 @@ def create_project():
)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not create project")}), 400
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
@@ -495,7 +510,7 @@ def update_project(project_id):
result = project_service.update_project(project_id=project_id, user_id=g.api_user.id, **update_kwargs)
if not result.get("success"):
return jsonify({"error": result.get("message", "Could not update project")}), 400
return error_response(result.get("message", "Could not update project"), status_code=400)
return jsonify({"message": "Project updated successfully", "project": result["project"].to_dict()})
@@ -526,7 +541,7 @@ def delete_project(project_id):
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 jsonify({"error": result.get("message", "Could not archive project")}), 404
return error_response(result.get("message", "Could not archive project"), status_code=404)
return jsonify({"message": "Project archived successfully"})
@@ -2453,6 +2468,509 @@ def delete_mileage(entry_id):
return jsonify({"message": "Mileage entry rejected successfully"})
# ==================== Deals (CRM) ====================
@api_v1_bp.route("/deals", methods=["GET"])
@require_api_token("read:deals")
def list_deals():
"""List deals with optional filters (status, stage, owner_id)."""
blocked = _require_module_enabled_for_api("deals")
if blocked:
return blocked
status = request.args.get("status", "open")
stage = request.args.get("stage", "")
owner_id = request.args.get("owner", type=int)
query = Deal.query
if status == "open":
query = query.filter_by(status="open")
elif status == "won":
query = query.filter_by(status="won")
elif status == "lost":
query = query.filter_by(status="lost")
if stage:
query = query.filter_by(stage=stage)
if owner_id and not g.api_user.is_admin:
query = query.filter_by(owner_id=g.api_user.id)
elif owner_id:
query = query.filter_by(owner_id=owner_id)
query = query.order_by(Deal.expected_close_date, Deal.created_at.desc())
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 50, type=int), 100)
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
pagination_dict = {
"page": pagination.page,
"per_page": pagination.per_page,
"total": pagination.total,
"pages": pagination.pages,
"has_next": pagination.has_next,
"has_prev": pagination.has_prev,
"next_page": pagination.page + 1 if pagination.has_next else None,
"prev_page": pagination.page - 1 if pagination.has_prev else None,
}
return jsonify({"deals": [d.to_dict() for d in pagination.items], "pagination": pagination_dict})
@api_v1_bp.route("/deals/<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_bp.route("/deals", methods=["POST"])
@require_api_token("write:deals")
def create_deal():
"""Create a deal."""
blocked = _require_module_enabled_for_api("deals")
if blocked:
return blocked
data = request.get_json() or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
from decimal import Decimal
value = None
if data.get("value") is not None:
try:
value = Decimal(str(data["value"]))
except Exception:
return error_response("Invalid value", status_code=400)
expected_close_date = _parse_date(data.get("expected_close_date"))
deal = Deal(
name=name,
created_by=g.api_user.id,
client_id=data.get("client_id"),
contact_id=data.get("contact_id"),
lead_id=data.get("lead_id"),
description=(data.get("description") or "").strip() or None,
stage=(data.get("stage") or "prospecting").strip(),
value=value,
currency_code=(data.get("currency_code") or "EUR").strip(),
probability=int(data.get("probability", 50)),
expected_close_date=expected_close_date,
status=(data.get("status") or "open").strip(),
loss_reason=(data.get("loss_reason") or "").strip() or None,
notes=(data.get("notes") or "").strip() or None,
owner_id=data.get("owner_id") or g.api_user.id,
related_quote_id=data.get("related_quote_id"),
related_project_id=data.get("related_project_id"),
)
db.session.add(deal)
db.session.commit()
return jsonify({"message": "Deal created successfully", "deal": deal.to_dict()}), 201
@api_v1_bp.route("/deals/<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 {}
from decimal import Decimal
for field in ("name", "description", "stage", "status", "loss_reason", "notes", "currency_code"):
if field in data and data[field] is not None:
setattr(deal, field, str(data[field]).strip() if isinstance(data[field], str) else data[field])
for field in ("client_id", "contact_id", "lead_id", "probability", "related_quote_id", "related_project_id", "owner_id"):
if field in data:
setattr(deal, field, data[field])
if "value" in data:
try:
deal.value = Decimal(str(data["value"])) if data["value"] is not None else None
except Exception:
pass
if "expected_close_date" in data:
deal.expected_close_date = _parse_date(data["expected_close_date"])
if "actual_close_date" in data:
deal.actual_close_date = _parse_date(data["actual_close_date"])
db.session.commit()
return jsonify({"message": "Deal updated successfully", "deal": deal.to_dict()})
@api_v1_bp.route("/deals/<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"})
# ==================== Leads (CRM) ====================
@api_v1_bp.route("/leads", methods=["GET"])
@require_api_token("read:leads")
def list_leads():
"""List leads with optional filters (status, source, owner)."""
blocked = _require_module_enabled_for_api("leads")
if blocked:
return blocked
status = request.args.get("status", "")
source = request.args.get("source", "")
owner_id = request.args.get("owner", type=int)
search = (request.args.get("search") or "").strip()
query = Lead.query
if status:
query = query.filter_by(status=status)
else:
query = query.filter(~Lead.status.in_(["converted", "lost"]))
if source:
query = query.filter_by(source=source)
if owner_id and not g.api_user.is_admin:
query = query.filter_by(owner_id=g.api_user.id)
elif owner_id:
query = query.filter_by(owner_id=owner_id)
if search:
like = f"%{search}%"
query = query.filter(or_(Lead.first_name.ilike(like), Lead.last_name.ilike(like), Lead.company_name.ilike(like), Lead.email.ilike(like)))
query = query.order_by(Lead.score.desc(), Lead.created_at.desc())
page = request.args.get("page", 1, type=int)
per_page = min(request.args.get("per_page", 50, type=int), 100)
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
pagination_dict = {
"page": pagination.page,
"per_page": pagination.per_page,
"total": pagination.total,
"pages": pagination.pages,
"has_next": pagination.has_next,
"has_prev": pagination.has_prev,
"next_page": pagination.page + 1 if pagination.has_next else None,
"prev_page": pagination.page - 1 if pagination.has_prev else None,
}
return jsonify({"leads": [l.to_dict() for l in pagination.items], "pagination": pagination_dict})
@api_v1_bp.route("/leads/<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_bp.route("/leads", methods=["POST"])
@require_api_token("write:leads")
def create_lead():
"""Create a lead."""
blocked = _require_module_enabled_for_api("leads")
if blocked:
return blocked
data = request.get_json() or {}
first_name = (data.get("first_name") or "").strip()
last_name = (data.get("last_name") or "").strip()
if not first_name or not last_name:
return error_response("first_name and last_name are required", status_code=400)
from decimal import Decimal
estimated_value = None
if data.get("estimated_value") is not None:
try:
estimated_value = Decimal(str(data["estimated_value"]))
except Exception:
pass
lead = Lead(
first_name=first_name,
last_name=last_name,
created_by=g.api_user.id,
company_name=(data.get("company_name") or "").strip() or None,
email=(data.get("email") or "").strip() or None,
phone=(data.get("phone") or "").strip() or None,
title=(data.get("title") or "").strip() or None,
source=(data.get("source") or "").strip() or None,
status=(data.get("status") or "new").strip(),
score=int(data.get("score", 0)),
estimated_value=estimated_value,
currency_code=(data.get("currency_code") or "EUR").strip(),
notes=(data.get("notes") or "").strip() or None,
tags=(data.get("tags") or "").strip() or None,
owner_id=data.get("owner_id") or g.api_user.id,
)
db.session.add(lead)
db.session.commit()
return jsonify({"message": "Lead created successfully", "lead": lead.to_dict()}), 201
@api_v1_bp.route("/leads/<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 {}
from decimal import Decimal
for field in ("first_name", "last_name", "company_name", "email", "phone", "title", "source", "status", "notes", "tags"):
if field in data and data[field] is not None:
setattr(lead, field, str(data[field]).strip() if isinstance(data[field], str) else data[field])
if "score" in data:
lead.score = int(data["score"])
if "estimated_value" in data:
try:
lead.estimated_value = Decimal(str(data["estimated_value"])) if data["estimated_value"] is not None else None
except Exception:
pass
if "owner_id" in data:
lead.owner_id = data["owner_id"]
db.session.commit()
return jsonify({"message": "Lead updated successfully", "lead": lead.to_dict()})
@api_v1_bp.route("/leads/<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"})
# ==================== Contacts (CRM) ====================
@api_v1_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
Client.query.filter_by(id=client_id).first_or_404()
contacts = Contact.get_active_contacts(client_id)
return jsonify({"contacts": [c.to_dict() for c in contacts]})
@api_v1_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
client = Client.query.filter_by(id=client_id).first_or_404()
data = request.get_json() or {}
first_name = (data.get("first_name") or "").strip()
last_name = (data.get("last_name") or "").strip()
if not first_name or not last_name:
return error_response("first_name and last_name are required", status_code=400)
contact = Contact(
client_id=client_id,
first_name=first_name,
last_name=last_name,
created_by=g.api_user.id,
email=(data.get("email") or "").strip() or None,
phone=(data.get("phone") or "").strip() or None,
mobile=(data.get("mobile") or "").strip() or None,
title=(data.get("title") or "").strip() or None,
department=(data.get("department") or "").strip() or None,
role=(data.get("role") or "contact").strip(),
is_primary=bool(data.get("is_primary", False)),
address=(data.get("address") or "").strip() or None,
notes=(data.get("notes") or "").strip() or None,
tags=(data.get("tags") or "").strip() or None,
)
db.session.add(contact)
if contact.is_primary:
Contact.query.filter(Contact.client_id == client_id, Contact.id != contact.id, Contact.is_primary == True).update({"is_primary": False})
db.session.commit()
return jsonify({"message": "Contact created successfully", "contact": contact.to_dict()}), 201
@api_v1_bp.route("/contacts/<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_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_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"})
# ==================== Time Entry Approvals ====================
@api_v1_bp.route("/time-entry-approvals", methods=["GET"])
@require_api_token("read:time_approvals")
def list_time_entry_approvals():
"""List pending time entry approvals for the current user (as approver)."""
blocked = _require_module_enabled_for_api("time_approvals")
if blocked:
return blocked
from app.services.time_approval_service import TimeApprovalService
service = TimeApprovalService()
approvals = service.get_pending_approvals(g.api_user.id)
return jsonify({"approvals": [a.to_dict() for a in approvals]})
@api_v1_bp.route("/time-entry-approvals/<int:approval_id>", methods=["GET"])
@require_api_token("read:time_approvals")
def get_time_entry_approval(approval_id):
"""Get a time entry approval by id."""
blocked = _require_module_enabled_for_api("time_approvals")
if blocked:
return blocked
approval = TimeEntryApproval.query.filter_by(id=approval_id).first_or_404()
from app.services.time_approval_service import TimeApprovalService
service = TimeApprovalService()
approver_ids = service._get_approvers_for_entry(approval.time_entry)
if approval.requested_by != g.api_user.id and (approval.approved_by or 0) != g.api_user.id:
if g.api_user.id not in approver_ids and not g.api_user.is_admin:
return forbidden_response("Access denied")
return jsonify({"approval": approval.to_dict()})
@api_v1_bp.route("/time-entry-approvals/<int:approval_id>/approve", methods=["POST"])
@require_api_token("write:time_approvals")
def approve_time_entry(approval_id):
"""Approve a time entry."""
blocked = _require_module_enabled_for_api("time_approvals")
if blocked:
return blocked
from app.services.time_approval_service import TimeApprovalService
service = TimeApprovalService()
data = request.get_json(silent=True) or {}
result = service.approve(approval_id=approval_id, approver_id=g.api_user.id, comment=data.get("comment"))
if not result.get("success"):
return error_response(result.get("message", "Approval failed"), status_code=400)
return jsonify(result)
@api_v1_bp.route("/time-entry-approvals/<int:approval_id>/reject", methods=["POST"])
@require_api_token("write:time_approvals")
def reject_time_entry(approval_id):
"""Reject a time entry."""
blocked = _require_module_enabled_for_api("time_approvals")
if blocked:
return blocked
from app.services.time_approval_service import TimeApprovalService
service = TimeApprovalService()
data = request.get_json(silent=True) or {}
reason = data.get("reason") or data.get("rejection_reason")
if not reason:
return error_response("Rejection reason required", status_code=400)
result = service.reject(approval_id=approval_id, approver_id=g.api_user.id, reason=reason)
if not result.get("success"):
return error_response(result.get("message", "Rejection failed"), status_code=400)
return jsonify(result)
@api_v1_bp.route("/time-entry-approvals/<int:approval_id>/cancel", methods=["POST"])
@require_api_token("write:time_approvals")
def cancel_time_entry_approval(approval_id):
"""Cancel an approval request (requester only)."""
blocked = _require_module_enabled_for_api("time_approvals")
if blocked:
return blocked
from app.services.time_approval_service import TimeApprovalService
service = TimeApprovalService()
result = service.cancel_approval(approval_id=approval_id, user_id=g.api_user.id)
if not result.get("success"):
return error_response(result.get("message", "Cancellation failed"), status_code=400)
return jsonify(result)
@api_v1_bp.route("/time-entries/<int:entry_id>/request-approval", methods=["POST"])
@require_api_token("write:time_approvals")
def request_time_entry_approval(entry_id):
"""Request approval for a time entry."""
blocked = _require_module_enabled_for_api("time_approvals")
if blocked:
return blocked
from app.services.time_approval_service import TimeApprovalService
service = TimeApprovalService()
data = request.get_json(silent=True) or {}
result = service.request_approval(
time_entry_id=entry_id,
requested_by=g.api_user.id,
comment=data.get("comment"),
approver_ids=data.get("approver_ids"),
)
if not result.get("success"):
return error_response(result.get("message", "Request failed"), status_code=400)
return jsonify(result)
@api_v1_bp.route("/time-entry-approvals/bulk-approve", methods=["POST"])
@require_api_token("write:time_approvals")
def bulk_approve_time_entries():
"""Bulk approve multiple time entry approvals."""
blocked = _require_module_enabled_for_api("time_approvals")
if blocked:
return blocked
from app.services.time_approval_service import TimeApprovalService
service = TimeApprovalService()
data = request.get_json(silent=True) or {}
approval_ids = data.get("approval_ids", [])
if not approval_ids:
return error_response("approval_ids required", status_code=400)
result = service.bulk_approve(approval_ids=approval_ids, approver_id=g.api_user.id, comment=data.get("comment"))
return jsonify(result)
# ==================== Per Diem ====================
+5 -3
View File
@@ -1,8 +1,10 @@
"""
Refactored invoice routes using service layer.
This demonstrates the new architecture pattern.
REFERENCE ONLY This module is not registered as an active blueprint.
To use: Replace functions in app/routes/invoices.py with these implementations.
Refactored invoice routes using service layer and app.utils.api_responses.
It demonstrates the intended architecture pattern. The active routes live in
app/routes/invoices.py. Do not register this blueprint; use it as reference when
refactoring or when adding new invoice routes.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
+5 -3
View File
@@ -1,8 +1,10 @@
"""
Example refactored projects route using service layer and fixing N+1 queries.
This demonstrates the new architecture pattern.
REFERENCE ONLY This module is not registered as an active blueprint.
To use: Replace the corresponding functions in app/routes/projects.py
Example refactored projects route using service layer and fixing N+1 queries.
It demonstrates the intended architecture pattern (service layer, eager loading).
The active routes live in app/routes/projects.py. Do not register this blueprint;
use it as reference when refactoring or when adding new project routes.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
+5 -3
View File
@@ -1,8 +1,10 @@
"""
Refactored timer routes using service layer.
This demonstrates the new architecture pattern.
REFERENCE ONLY This module is not registered as an active blueprint.
To use: Replace functions in app/routes/timer.py with these implementations.
Refactored timer routes using service layer and app.utils.api_responses.
It demonstrates the intended architecture pattern. The active routes live in
app/routes/timer.py. Do not register this blueprint; use it as reference when
refactoring or when adding new timer routes.
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
+8
View File
@@ -253,11 +253,19 @@ class ApiTokenService:
"read:clients",
"read:tasks",
"read:reports",
"read:deals",
"read:leads",
"read:contacts",
"read:time_approvals",
"write:projects",
"write:time_entries",
"write:invoices",
"write:clients",
"write:tasks",
"write:deals",
"write:leads",
"write:contacts",
"write:time_approvals",
"admin:all",
"*",
]
+4
View File
@@ -1,6 +1,10 @@
"""
Base CRUD service to reduce code duplication across services.
Provides common CRUD operations with consistent error handling.
Optional use: extend this class when adding a new domain that has a repository
and simple CRUD needs. Existing domain services do not use it. See
docs/development/SERVICE_LAYER_AND_BASE_CRUD.md for the chosen service pattern.
"""
from typing import TypeVar, Generic, Optional, Dict, Any, List
+32
View File
@@ -253,6 +253,38 @@
<input type="checkbox" name="scopes" value="write:calendar" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:calendar - Create/update calendar events</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:deals" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:deals - View deals</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:deals" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:deals - Create/update deals</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:leads" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:leads - View leads</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:leads" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:leads - Create/update leads</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:contacts" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:contacts - View contacts</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:contacts" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:contacts - Create/update contacts</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:time_approvals" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:time_approvals - View time entry approvals</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:time_approvals" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:time_approvals - Approve/reject time entries</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:budget_alerts" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:budget_alerts - View budget alerts</span>
+2 -2
View File
@@ -1,7 +1,7 @@
# TimeTracker - Complete Features Documentation
**Version:** 4.14.0
**Last Updated:** 2025-01-27
**Version:** 4.19.0 (see `setup.py` for single source of truth)
**Last Updated:** 2025-02-13
---
+45
View File
@@ -0,0 +1,45 @@
# RBAC Permission Model (Route-Level)
This document describes how route-level access control is applied across the application. For the full role and permission system (roles, permissions, categories), see [ADVANCED_PERMISSIONS.md](../ADVANCED_PERMISSIONS.md).
## Two patterns
### 1. Permission-scoped routes
These blueprints protect routes with `@admin_or_permission_required("permission_name")` in addition to `@login_required`. Only users who are admins or have the given permission can access the route.
**Blueprints using permission decorators:**
- **admin** `access_admin`, `view_users`, `create_users`, `edit_users`, `delete_users`, `manage_telemetry`, `manage_settings`, `manage_backups`, `view_system_info`, `manage_oidc`, `manage_api_tokens`, `manage_integrations`
- **audit_logs** `view_audit_logs`
- **per_diem** `per_diem_rates.view`, `per_diem_rates.create`, `per_diem_rates.edit`, `per_diem_rates.delete`
- **inventory** `view_inventory`, `manage_stock_items`, `manage_warehouses`, `view_stock_levels`, `view_stock_history`, `manage_stock_movements`, `transfer_stock`, `manage_stock_reservations`, `manage_suppliers`, `manage_purchase_orders`, `view_inventory_reports`
- **clients** permission checks where applicable
- **projects** `create_projects` (and others where applied)
- **kanban** permission checks where applied
- **webhooks** permission checks where applied
- **project_templates** permission checks where applied
- **quotes** permission checks where applied
- **custom_field_definitions** permission checks where applied
- **invoice_approvals** permission checks where applied
- **payment_gateways** permission checks where applied
- **kiosk** permission checks where applied
- **offers** permission checks where applied
- **link_templates** permission checks where applied
- **expense_categories** permission checks where applied
### 2. All authenticated users
These blueprints use only `@login_required`. Any logged-in user can access the routes. This is intentional for areas where the default roles (e.g. User, Manager) are expected to have full access within the app.
**Examples:** deals, leads, invoices (main routes), timer, reports, calendar, expenses (main routes), main dashboard, time_approvals, contacts, tasks, client_notes, budget_alerts, payments, recurring_invoices, etc.
## When to add permission decorators
- **New admin-only or sensitive feature:** Use `@admin_or_permission_required("appropriate_permission")` and define the permission in the permission system if it does not exist.
- **New feature for all users:** Use only `@login_required`.
- **Existing “login only” route:** Leave as-is unless you are explicitly tightening access; then add a permission and document it in ADVANCED_PERMISSIONS.md.
## API v1 (REST)
REST API v1 uses API token scopes (e.g. `read:deals`, `write:time_entries`) rather than web permission names. See [API Token Scopes](../api/API_TOKEN_SCOPES.md) and [REST_API.md](../api/REST_API.md).
@@ -0,0 +1,28 @@
# Service Layer and Base CRUD Pattern
## Chosen pattern
TimeTracker uses a **domain service layer**: route handlers call service classes (e.g. `ProjectService`, `InvoiceService`, `TimeApprovalService`) that encapsulate business logic and data access. Routes are kept thin; validation, permissions, and orchestration live in services or in route-level decorators.
- **Services** live in `app/services/`. Each domain (projects, clients, time entries, invoices, approvals, etc.) has one or more service classes.
- **Repositories** exist for some domains (`app/repositories/`, e.g. `TimeEntryRepository`, `ProjectRepository`, `ClientRepository`, `TaskRepository`) and are used by services or routes to avoid N+1 queries and centralize queries.
- **Routes** use `db.session` and model queries where a dedicated service or repository is not yet introduced; new features and refactors should prefer the service (and optionally repository) pattern.
## BaseCRUDService
`app/services/base_crud_service.py` defines **BaseCRUDService**, a generic base class that provides standard CRUD (get_by_id, create, update, delete, list_all) with consistent `{ "success", "message", "data" / "error" }` result dicts.
- **Current use**: BaseCRUDService is **not** extended by any service today. Domain services implement their own methods and return shapes (e.g. `ProjectService.create()` returns a result dict used by the API).
- **When to use it**: Prefer BaseCRUDService when:
- You introduce a **new** domain that has a **repository** with `get_by_id`, `create`, `update`, `delete`, and `query()`.
- The resource is mostly simple CRUD with minimal extra logic.
- **When not to use it**: Existing domain services (projects, clients, invoices, time entries, etc.) have custom logic, validation, and return shapes. Migrating them to BaseCRUDService would require repository implementations and possible API response changes; it is optional and can be done incrementally.
## Summary
| Aspect | Approach |
|---------------------|--------------------------------------------------------------------------|
| New features | Prefer service class + optional repository; use BaseCRUDService only if CRUD is simple and a repository exists. |
| Existing services | Keep current pattern; no requirement to extend BaseCRUDService. |
| Route layer | Prefer calling services; direct `db.session` / model queries are acceptable where services are not yet used. |
| API response shape | Use `app.utils.api_responses` (e.g. `error_response`, `success_response`) for consistent JSON error/success format. |
+1 -1
View File
@@ -119,7 +119,7 @@ Both apps support offline operation:
## Version Management
App versions should align with the backend version (currently 4.9.16). Update version numbers in:
App versions should align with the backend version (see `setup.py` for current version). Update version numbers in:
- Mobile: `mobile/pubspec.yaml`
- Desktop: `desktop/package.json`
+1 -1
View File
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='4.19.0',
version='4.19.1',
packages=find_packages(),
include_package_data=True,
install_requires=[
-106
View File
@@ -1,106 +0,0 @@
{% extends 'base.html' %}
{% block title %}{{ _('Restore Backup') }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-undo-alt text-danger"></i>
{{ _('Restore Backup') }}
</h1>
<span class="badge bg-danger fs-6">{{ _('Danger Operation') }}</span>
</div>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{ _('Back to Dashboard') }}
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-file-archive me-2"></i>{{ _('Upload Backup Archive (.zip)') }}
</h6>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ _('Restoring a backup will overwrite your current database and files. Ensure you have a recent backup before proceeding.') }}
</div>
{% if progress %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>{{ _('Status:') }}</strong>
<span class="badge {{ 'bg-success' if progress.status == 'done' else ('bg-danger' if progress.status == 'error' else 'bg-info') }}">
{{ progress.status|title }}
</span>
</div>
<div class="progress progress-thin mb-2">
<div class="progress-bar" role="progressbar" style="width: {{ progress.percent }}%">
{{ progress.percent }}%
</div>
</div>
<div class="text-muted small">{{ progress.message }}</div>
</div>
<script>
// Auto-refresh progress every 2s while running
(function(){
const status = "{{ progress.status }}";
if (status === 'running') {
setTimeout(function(){ window.location.href = "{{ url_for('admin.restore', token=token) }}"; }, 2000);
}
})();
</script>
{% endif %}
<form action="{{ url_for('admin.restore') }}" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label for="backup_file" class="form-label">{{ _('Backup Archive (.zip)') }}</label>
<input class="form-control" type="file" id="backup_file" name="backup_file" accept=".zip" required>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
{{ _('Select a .zip archive previously created via the Backup action.') }}
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="fas fa-undo-alt me-1"></i> {{ _('Restore') }}
</button>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
{{ _('Cancel') }}
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-shield-alt me-2"></i>{{ _('Safety Tips') }}
</h6>
</div>
<div class="card-body">
<ul class="text-muted mb-0">
<li>{{ _('Verify the backup archive integrity before restoring.') }}</li>
<li>{{ _('Ensure no active writes are occurring during restore.') }}</li>
<li>{{ _('Keep a copy of the current data in case you need to roll back.') }}</li>
<li>{{ _('After restore, review settings and re-run migrations if required.') }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}