mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 01:49:35 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 ====================
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
"*",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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. |
|
||||
@@ -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`
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user