mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
@@ -27,10 +27,10 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
skip_tests:
|
||||
description: 'Skip tests (tests already ran on PR, only for workflow_dispatch)'
|
||||
description: 'Skip tests (opt-in only; default is to run tests on manual release)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
default: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
@@ -7,13 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
- **Quote create returned HTTP 500 after save (#583)** — The quote was persisted, but the redirect to the quote detail page crashed because the view expected `requires_approval` and `can_be_sent`, and templates could set `approval_level`, while the `Quote` model had no matching fields. Added `requires_approval` and `approval_level` columns, a `can_be_sent` property aligned with send rules, wired the create-form checkbox, fixed the approval banner branch to use `approval_status == 'not_required'`, migration `145_add_quotes_requires_approval`, and regression coverage in `tests/test_routes/test_quotes_web.py`.
|
||||
|
||||
### Added
|
||||
- **Quote line item reorder (Issue #584)** — Non-null `quote_items.position` (migration `146_add_quote_item_position`); `Quote.items` is ordered by `position`, then `id`. Create, edit, duplicate, bulk duplicate, API item payloads, and quote-template apply assign positions from the submitted row order. **Create quote** and **edit quote** line tables include **Move up** / **Move down** per row so items can be reordered without deleting and re-entering lines; PDFs and detail views follow the saved order.
|
||||
- **Offline queue replay** — Queued requests now store method, headers, and body in a replay-safe form (serializable for localStorage). POST/PUT requests replayed when back online send the same body and method. Legacy queue items (with `options` only) are still replayed via fallback.
|
||||
- **Inventory API scopes** — New scopes `read:inventory` and `write:inventory` for inventory-only API access. Existing `read:projects` and `write:projects` still grant the same inventory access for backward compatibility.
|
||||
- **Client portal reports: date range and CSV export** — Reports support optional `days` query param (1–365, default 30). Add `?format=csv` to download a CSV of the same report (summary, hours by project, time by date). Export uses the same access control as the reports page.
|
||||
- **Jira webhook verification** — When a webhook secret is configured in the Jira integration (Connection Settings → Webhook Secret), incoming webhooks are verified using HMAC-SHA256 of the request body. Supported headers: `X-Hub-Signature-256`, `X-Atlassian-Webhook-Signature`, `X-Hub-Signature`. Requests with missing or invalid signature are rejected. If no secret is set, behavior is unchanged (all webhooks accepted).
|
||||
|
||||
### Changed
|
||||
- **Factur-X / PDF/A-3 invoice PDFs (export and email)** — Download and email attachments use the same embed-and-normalize path. Embedded CII uses Associated File relationship **Data** and MIME **text/xml**. PDF/A-3 normalization embeds sRGB via `app/resources/icc/` (override with `INVOICE_SRGB_ICC_PATH`). Added `app/utils/invoice_pdf_postprocess.py` and tests; [PEPPOL e-Invoicing](docs/admin/configuration/PEPPOL_EINVOICING.md) updated (veraPDF note, pytest command).
|
||||
- **Documentation sync** — CODEBASE_AUDIT.md: marked gaps 2.3–2.7 and 2.9 as fixed; added “Implemented 2026-03-16” summary. CLIENT_FEATURES_IMPLEMENTATION_STATUS: report date range and CSV export noted as implemented. INCOMPLETE_IMPLEMENTATIONS_ANALYSIS: added “Verified 2026-03-16” for webhook verification, issues permissions, search API, offline queue.
|
||||
- **Activity feed API date params** — `/api/activity` now returns 400 with a clear message when `start_date` or `end_date` are invalid (e.g. not ISO 8601). Invalid dates on the web route `/activity` are logged and the filter is skipped (no 500).
|
||||
- **Invoice PEPPOL compliance check** — Exceptions in the PEPPOL compliance block are no longer silently ignored: specific and generic exceptions are caught, logged, and a generic warning (“Could not verify PEPPOL compliance; check configuration.”) is shown to the user so the view still renders.
|
||||
|
||||
@@ -53,6 +53,10 @@ class Config:
|
||||
# API token default expiry (days); 0 or empty = never expire (not recommended for production)
|
||||
API_TOKEN_DEFAULT_EXPIRY_DAYS = int(os.getenv("API_TOKEN_DEFAULT_EXPIRY_DAYS", "90"))
|
||||
|
||||
# Per-token REST API rate limits (enforced in require_api_token when Redis or local fallback is used)
|
||||
API_TOKEN_RATE_LIMIT_PER_MINUTE = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_MINUTE", "100"))
|
||||
API_TOKEN_RATE_LIMIT_PER_HOUR = int(os.getenv("API_TOKEN_RATE_LIMIT_PER_HOUR", "1000"))
|
||||
|
||||
# Authentication method: 'none' | 'local' | 'oidc' | 'both'
|
||||
# 'none' = no password authentication (username only)
|
||||
# 'local' = password authentication required
|
||||
|
||||
@@ -180,6 +180,9 @@ class WebhookEvent(Enum):
|
||||
INTEGRATION_DELETED = "integration.deleted"
|
||||
INTEGRATION_SYNCED = "integration.synced"
|
||||
INTEGRATION_ERROR = "integration.error"
|
||||
API_TOKEN_CREATED = "api_token.created"
|
||||
API_TOKEN_ROTATED = "api_token.rotated"
|
||||
API_TOKEN_REVOKED = "api_token.revoked"
|
||||
|
||||
|
||||
# Notification types
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional
|
||||
import requests
|
||||
|
||||
from app.integrations.base import BaseConnector
|
||||
from app.utils.integration_http import integration_session, session_request
|
||||
from app.utils.timezone import get_timezone_obj, utc_to_local
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -74,17 +75,18 @@ class ActivityWatchConnector(BaseConnector):
|
||||
base = self._get_server_url()
|
||||
url = f"{base}/api/0/{path.lstrip('/')}"
|
||||
try:
|
||||
resp = requests.get(url, params=params, timeout=15)
|
||||
session = integration_session()
|
||||
resp = session_request(session, "GET", url, params=params, timeout=(5, 20))
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON from ActivityWatch: {e}") from e
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
raise ValueError(f"Cannot reach ActivityWatch at {base}: {e}") from e
|
||||
except requests.exceptions.Timeout as e:
|
||||
raise ValueError(f"ActivityWatch request timed out: {e}") from e
|
||||
except requests.exceptions.HTTPError as e:
|
||||
raise ValueError(f"ActivityWatch API error: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON from ActivityWatch: {e}") from e
|
||||
|
||||
def test_connection(self) -> Dict[str, Any]:
|
||||
"""Test connectivity to aw-server: GET /api/0/buckets/."""
|
||||
|
||||
+51
-27
@@ -163,6 +163,18 @@ class AsanaConnector(BaseConnector):
|
||||
"""Sync tasks and projects with Asana."""
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import (
|
||||
ensure_project_integration_fields,
|
||||
find_project_by_integration_ref,
|
||||
find_task_by_integration_ref,
|
||||
require_sync_context,
|
||||
set_task_integration_ref,
|
||||
)
|
||||
|
||||
try:
|
||||
actor_id, client_id = require_sync_context(self.integration)
|
||||
except ValueError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {self.get_access_token()}"}
|
||||
@@ -187,25 +199,30 @@ class AsanaConnector(BaseConnector):
|
||||
|
||||
for asana_project in asana_projects:
|
||||
try:
|
||||
# Find or create project
|
||||
project = Project.query.filter_by(
|
||||
user_id=self.integration.user_id, name=asana_project.get("name")
|
||||
).first()
|
||||
ap_gid = str(asana_project.get("gid") or "")
|
||||
ap_name = (asana_project.get("name") or "Asana project").strip()[:200]
|
||||
if not ap_gid:
|
||||
continue
|
||||
|
||||
project = find_project_by_integration_ref(client_id, "asana", ap_gid)
|
||||
if not project:
|
||||
project = Project.query.filter_by(client_id=client_id, name=ap_name).first()
|
||||
if not project:
|
||||
project = Project(
|
||||
name=asana_project.get("name"),
|
||||
description=asana_project.get("notes", ""),
|
||||
user_id=self.integration.user_id,
|
||||
name=ap_name,
|
||||
client_id=client_id,
|
||||
description=(asana_project.get("notes") or "") or None,
|
||||
status="active" if not asana_project.get("archived") else "archived",
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
|
||||
# Store Asana project GID in project metadata
|
||||
if not hasattr(project, "metadata") or not project.metadata:
|
||||
project.metadata = {}
|
||||
project.metadata["asana_project_gid"] = asana_project.get("gid")
|
||||
ensure_project_integration_fields(
|
||||
project,
|
||||
source="asana",
|
||||
ref=ap_gid,
|
||||
display_name=ap_name,
|
||||
description=(asana_project.get("notes") or "") or None,
|
||||
)
|
||||
|
||||
# Sync tasks from Asana project
|
||||
tasks_response = requests.get(
|
||||
@@ -219,41 +236,48 @@ class AsanaConnector(BaseConnector):
|
||||
|
||||
for asana_task in asana_tasks:
|
||||
try:
|
||||
# Get task details
|
||||
at_gid = str(asana_task.get("gid") or "")
|
||||
if not at_gid:
|
||||
continue
|
||||
task_response = requests.get(
|
||||
f"{self.BASE_URL}/tasks/{asana_task.get('gid')}",
|
||||
f"{self.BASE_URL}/tasks/{at_gid}",
|
||||
headers=headers,
|
||||
params={"opt_fields": "name,notes,completed,due_on,assignee"},
|
||||
)
|
||||
|
||||
if task_response.status_code == 200:
|
||||
task_data = task_response.json().get("data", {})
|
||||
tname = (task_data.get("name") or "Task").strip()[:200]
|
||||
tstatus = "done" if task_data.get("completed") else "todo"
|
||||
|
||||
# Find or create task
|
||||
task = Task.query.filter_by(
|
||||
project_id=project.id, name=task_data.get("name", "")
|
||||
).first()
|
||||
|
||||
task = find_task_by_integration_ref(project.id, at_gid, source="asana")
|
||||
if not task:
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name=task_data.get("name", ""),
|
||||
description=task_data.get("notes", ""),
|
||||
status="completed" if task_data.get("completed") else "todo",
|
||||
name=tname,
|
||||
description=(task_data.get("notes") or "") or None,
|
||||
status=tstatus,
|
||||
created_by=actor_id,
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
else:
|
||||
task.name = tname
|
||||
task.description = (task_data.get("notes") or "") or None
|
||||
task.status = tstatus
|
||||
|
||||
# Store Asana task GID in metadata
|
||||
if not hasattr(task, "metadata") or not task.metadata:
|
||||
task.metadata = {}
|
||||
task.metadata["asana_task_gid"] = asana_task.get("gid")
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="asana",
|
||||
ref=at_gid,
|
||||
extra={"asana_task_gid": at_gid},
|
||||
)
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
errors.append(
|
||||
f"Error syncing task in project {asana_project.get('name')}: {str(e)}"
|
||||
)
|
||||
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Error syncing project {asana_project.get('name')}: {str(e)}")
|
||||
|
||||
|
||||
+71
-26
@@ -144,6 +144,13 @@ class GitHubConnector(BaseConnector):
|
||||
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import (
|
||||
ensure_project_integration_fields,
|
||||
find_project_by_integration_ref,
|
||||
find_task_by_integration_ref,
|
||||
require_sync_context,
|
||||
set_task_integration_ref,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -151,6 +158,11 @@ class GitHubConnector(BaseConnector):
|
||||
if not token:
|
||||
return {"success": False, "message": "No access token available. Please reconnect the integration."}
|
||||
|
||||
try:
|
||||
actor_id, client_id = require_sync_context(self.integration)
|
||||
except ValueError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Get repositories from config
|
||||
repos_str = self.integration.config.get("repositories", "")
|
||||
if not repos_str:
|
||||
@@ -200,15 +212,16 @@ class GitHubConnector(BaseConnector):
|
||||
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
|
||||
# Find or create project
|
||||
project = Project.query.filter_by(user_id=self.integration.user_id, name=repo).first()
|
||||
|
||||
# Find or create project (client + custom_fields integration marker)
|
||||
project = find_project_by_integration_ref(client_id, "github", repo)
|
||||
if not project:
|
||||
project = Project.query.filter_by(client_id=client_id, name=repo).first()
|
||||
if not project:
|
||||
try:
|
||||
project = Project(
|
||||
name=repo,
|
||||
client_id=client_id,
|
||||
description=f"GitHub repository: {repo}",
|
||||
user_id=self.integration.user_id,
|
||||
status="active",
|
||||
)
|
||||
db.session.add(project)
|
||||
@@ -217,6 +230,13 @@ class GitHubConnector(BaseConnector):
|
||||
errors.append(f"Error creating project for {repo}: {str(e)}")
|
||||
logger.error(f"Error creating project for {repo}: {e}", exc_info=True)
|
||||
continue
|
||||
ensure_project_integration_fields(
|
||||
project,
|
||||
source="github",
|
||||
ref=repo,
|
||||
display_name=repo,
|
||||
description=f"GitHub repository: {repo}",
|
||||
)
|
||||
|
||||
# Fetch issues
|
||||
try:
|
||||
@@ -254,25 +274,33 @@ class GitHubConnector(BaseConnector):
|
||||
|
||||
for issue in issues:
|
||||
try:
|
||||
if issue.get("pull_request"):
|
||||
continue
|
||||
issue_number = issue.get("number")
|
||||
issue_title = issue.get("title", "")
|
||||
issue_title = (issue.get("title") or "").strip() or "Issue"
|
||||
issue_title = issue_title[:180]
|
||||
|
||||
if not issue_number:
|
||||
continue
|
||||
|
||||
# Find or create task
|
||||
task = Task.query.filter_by(
|
||||
project_id=project.id, name=f"#{issue_number}: {issue_title}"
|
||||
).first()
|
||||
issue_ref = f"{repo}#{issue_number}"
|
||||
body = (issue.get("body") or "").strip()
|
||||
url = issue.get("html_url") or ""
|
||||
if url:
|
||||
body = f"{body}\n\nGitHub: {url}" if body else f"GitHub: {url}"
|
||||
gh_state = (issue.get("state") or "").lower()
|
||||
task_status = "done" if gh_state == "closed" else "todo"
|
||||
|
||||
task = find_task_by_integration_ref(project.id, issue_ref, source="github")
|
||||
if not task:
|
||||
try:
|
||||
task_name = f"#{issue_number}: {issue_title}"[:200]
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name=f"#{issue_number}: {issue_title}",
|
||||
description=issue.get("body", ""),
|
||||
status="todo",
|
||||
notes=f"GitHub Issue: {issue.get('html_url', '')}",
|
||||
name=task_name,
|
||||
description=body or None,
|
||||
status=task_status,
|
||||
created_by=actor_id,
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
@@ -282,17 +310,22 @@ class GitHubConnector(BaseConnector):
|
||||
f"Error creating task for issue #{issue_number} in {repo}: {e}", exc_info=True
|
||||
)
|
||||
continue
|
||||
else:
|
||||
task.name = f"#{issue_number}: {issue_title}"[:200]
|
||||
task.description = body or None
|
||||
task.status = task_status
|
||||
|
||||
# Store GitHub issue info in task metadata
|
||||
try:
|
||||
if not hasattr(task, "metadata") or not task.metadata:
|
||||
task.metadata = {}
|
||||
task.metadata["github_repo"] = repo
|
||||
task.metadata["github_issue_number"] = issue_number
|
||||
task.metadata["github_issue_id"] = issue.get("id")
|
||||
task.metadata["github_issue_url"] = issue.get("html_url")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error updating task metadata for issue #{issue_number}: {e}")
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="github",
|
||||
ref=issue_ref,
|
||||
extra={
|
||||
"issue_number": issue_number,
|
||||
"issue_id": issue.get("id"),
|
||||
"url": url,
|
||||
"repo": repo,
|
||||
},
|
||||
)
|
||||
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
@@ -335,7 +368,12 @@ class GitHubConnector(BaseConnector):
|
||||
db.session.rollback()
|
||||
except Exception as rollback_err:
|
||||
logger.debug("Rollback after GitHub sync failure: %s", rollback_err)
|
||||
return {"success": False, "message": f"Sync failed: {str(e)}", "errors": errors}
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Sync failed: {str(e)}",
|
||||
"errors": errors,
|
||||
"synced_items": synced_count,
|
||||
}
|
||||
|
||||
def handle_webhook(
|
||||
self, payload: Dict[str, Any], headers: Dict[str, str], raw_body: Optional[bytes] = None
|
||||
@@ -387,12 +425,19 @@ class GitHubConnector(BaseConnector):
|
||||
logger.warning("GitHub webhook signature provided but no secret configured - rejecting webhook")
|
||||
return {"success": False, "message": "Webhook secret not configured"}
|
||||
else:
|
||||
# No signature provided - check if secret is configured
|
||||
# No signature: always reject (configure secret on GitHub + matching webhook_secret here)
|
||||
webhook_secret = self.integration.config.get("webhook_secret") if self.integration else None
|
||||
if webhook_secret:
|
||||
# Secret configured but no signature - reject for security
|
||||
logger.warning("GitHub webhook secret configured but no signature provided - rejecting webhook")
|
||||
return {"success": False, "message": "Webhook signature required but not provided"}
|
||||
logger.warning(
|
||||
"GitHub webhook rejected: missing X-Hub-Signature-256. "
|
||||
"Set a secret on the GitHub webhook and store it in integration config as webhook_secret."
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Webhook signature required; configure webhook_secret on GitHub and in TimeTracker.",
|
||||
}
|
||||
|
||||
# Process webhook event
|
||||
action = payload.get("action")
|
||||
|
||||
+128
-19
@@ -190,48 +190,157 @@ class GitLabConnector(BaseConnector):
|
||||
return {"success": False, "message": f"Connection error: {str(e)}"}
|
||||
|
||||
def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
|
||||
"""Sync issues from GitLab repositories."""
|
||||
"""Sync issues from GitLab repositories into TimeTracker projects and tasks."""
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import (
|
||||
ensure_project_integration_fields,
|
||||
find_project_by_integration_ref,
|
||||
find_task_by_integration_ref,
|
||||
require_sync_context,
|
||||
set_task_integration_ref,
|
||||
)
|
||||
|
||||
token = self.get_access_token()
|
||||
if not token:
|
||||
return {"success": False, "message": "No access token available"}
|
||||
|
||||
try:
|
||||
actor_id, client_id = require_sync_context(self.integration)
|
||||
except ValueError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
base_url = self._get_base_url()
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
synced_count = 0
|
||||
errors = []
|
||||
|
||||
try:
|
||||
# Get repositories from config or all accessible repos
|
||||
repo_ids = self.integration.config.get("repository_ids", [])
|
||||
raw_ids = self.integration.config.get("repository_ids", []) if self.integration else []
|
||||
repo_ids: List[int] = []
|
||||
if isinstance(raw_ids, str):
|
||||
for part in raw_ids.split(","):
|
||||
part = part.strip()
|
||||
if part.isdigit():
|
||||
repo_ids.append(int(part))
|
||||
elif isinstance(raw_ids, list):
|
||||
for x in raw_ids:
|
||||
try:
|
||||
repo_ids.append(int(x))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
try:
|
||||
if not repo_ids:
|
||||
# Get all accessible projects
|
||||
projects_response = requests.get(
|
||||
f"{base_url}/api/v4/projects",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
headers=headers,
|
||||
params={"membership": True, "per_page": 100},
|
||||
timeout=30,
|
||||
)
|
||||
if projects_response.status_code == 200:
|
||||
projects = projects_response.json()
|
||||
repo_ids = [p["id"] for p in projects]
|
||||
if projects_response.status_code != 200:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Could not list GitLab projects: HTTP {projects_response.status_code}",
|
||||
}
|
||||
repo_ids = [p["id"] for p in projects_response.json()[:20]]
|
||||
|
||||
# Sync issues from each repository
|
||||
for repo_id in repo_ids:
|
||||
try:
|
||||
issues_response = requests.get(
|
||||
f"{base_url}/api/v4/projects/{repo_id}/issues",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
params={"state": "opened", "per_page": 100},
|
||||
pr = requests.get(f"{base_url}/api/v4/projects/{repo_id}", headers=headers, timeout=30)
|
||||
if pr.status_code != 200:
|
||||
errors.append(f"GitLab project {repo_id}: HTTP {pr.status_code}")
|
||||
continue
|
||||
gl_project = pr.json()
|
||||
path = gl_project.get("path_with_namespace") or gl_project.get("name") or str(repo_id)
|
||||
path = str(path)[:200]
|
||||
project_ref = str(repo_id)
|
||||
|
||||
project = find_project_by_integration_ref(client_id, "gitlab", project_ref)
|
||||
if not project:
|
||||
project = Project.query.filter_by(client_id=client_id, name=path).first()
|
||||
if not project:
|
||||
project = Project(
|
||||
name=path,
|
||||
client_id=client_id,
|
||||
description=(gl_project.get("description") or "") or f"GitLab: {path}",
|
||||
status="active",
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
ensure_project_integration_fields(
|
||||
project,
|
||||
source="gitlab",
|
||||
ref=project_ref,
|
||||
display_name=path,
|
||||
description=(gl_project.get("description") or "") or f"GitLab: {path}",
|
||||
)
|
||||
|
||||
if issues_response.status_code == 200:
|
||||
issues = issues_response.json()
|
||||
synced_count += len(issues)
|
||||
issues_response = requests.get(
|
||||
f"{base_url}/api/v4/projects/{repo_id}/issues",
|
||||
headers=headers,
|
||||
params={"state": "opened", "per_page": 100},
|
||||
timeout=30,
|
||||
)
|
||||
if issues_response.status_code != 200:
|
||||
errors.append(f"GitLab issues for project {repo_id}: HTTP {issues_response.status_code}")
|
||||
continue
|
||||
|
||||
for issue in issues_response.json():
|
||||
iid = issue.get("iid")
|
||||
if not iid:
|
||||
continue
|
||||
title = (issue.get("title") or "Issue").strip()[:180]
|
||||
issue_ref = f"{repo_id}:{iid}"
|
||||
desc = (issue.get("description") or "").strip()
|
||||
web_url = issue.get("web_url") or ""
|
||||
if web_url:
|
||||
desc = f"{desc}\n\nGitLab: {web_url}" if desc else f"GitLab: {web_url}"
|
||||
state = (issue.get("state") or "").lower()
|
||||
task_status = "done" if state in ("closed", "merged") else "todo"
|
||||
task_name = f"#{iid}: {title}"[:200]
|
||||
|
||||
task = find_task_by_integration_ref(project.id, issue_ref, source="gitlab")
|
||||
if not task:
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name=task_name,
|
||||
description=desc or None,
|
||||
status=task_status,
|
||||
created_by=actor_id,
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
else:
|
||||
task.name = task_name
|
||||
task.description = desc or None
|
||||
task.status = task_status
|
||||
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="gitlab",
|
||||
ref=issue_ref,
|
||||
extra={
|
||||
"gitlab_project_id": repo_id,
|
||||
"iid": iid,
|
||||
"id": issue.get("id"),
|
||||
"url": web_url,
|
||||
},
|
||||
)
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Error syncing repository {repo_id}: {str(e)}")
|
||||
|
||||
return {"success": True, "message": "Sync completed", "synced_items": synced_count, "errors": errors}
|
||||
db.session.commit()
|
||||
msg = f"Sync completed. Upserted {synced_count} issue(s)."
|
||||
if errors:
|
||||
msg += f" {len(errors)} error(s)."
|
||||
return {"success": True, "message": msg, "synced_items": synced_count, "errors": errors}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Sync failed: {str(e)}"}
|
||||
try:
|
||||
db.session.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
return {"success": False, "message": f"Sync failed: {str(e)}", "errors": errors}
|
||||
|
||||
def get_config_schema(self) -> Dict[str, Any]:
|
||||
"""Get configuration schema."""
|
||||
|
||||
+53
-28
@@ -172,13 +172,19 @@ class JiraConnector(BaseConnector):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _upsert_task_from_issue(self, issue: Dict[str, Any]) -> int:
|
||||
def _upsert_task_from_issue(self, issue: Dict[str, Any], actor_id: int, client_id: int) -> int:
|
||||
"""
|
||||
Find or create Project and Task from a single Jira issue dict.
|
||||
Reuses same mapping logic as sync_data. Returns 1 if upserted, 0 on skip/error.
|
||||
"""
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import (
|
||||
ensure_project_integration_fields,
|
||||
find_project_by_integration_ref,
|
||||
find_task_by_integration_ref,
|
||||
set_task_integration_ref,
|
||||
)
|
||||
|
||||
issue_key = issue.get("key")
|
||||
if not issue_key:
|
||||
@@ -187,51 +193,56 @@ class JiraConnector(BaseConnector):
|
||||
project_key = (issue_fields.get("project") or {}).get("key") or ""
|
||||
project_key = project_key or "Jira"
|
||||
|
||||
project = Project.query.filter_by(
|
||||
user_id=self.integration.user_id, name=project_key
|
||||
).first()
|
||||
|
||||
project = find_project_by_integration_ref(client_id, "jira", project_key)
|
||||
if not project:
|
||||
project = Project.query.filter_by(client_id=client_id, name=project_key).first()
|
||||
if not project:
|
||||
project = Project(
|
||||
name=project_key,
|
||||
client_id=client_id,
|
||||
description=f"Synced from Jira project {project_key}",
|
||||
user_id=self.integration.user_id,
|
||||
status="active",
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
ensure_project_integration_fields(
|
||||
project,
|
||||
source="jira",
|
||||
ref=project_key,
|
||||
display_name=project_key,
|
||||
description=f"Synced from Jira project {project_key}",
|
||||
)
|
||||
|
||||
task = Task.query.filter_by(project_id=project.id, name=issue_key).first()
|
||||
summary = issue_fields.get("summary") or ""
|
||||
status_name = (issue_fields.get("status") or {}).get("name") or "To Do"
|
||||
mapped_status = self._map_jira_status(status_name)
|
||||
description_text = self._extract_description_text(issue_fields)
|
||||
desc = summary
|
||||
if description_text:
|
||||
desc = f"{summary}\n\n{description_text}" if summary else description_text
|
||||
|
||||
task = find_task_by_integration_ref(project.id, issue_key, source="jira")
|
||||
if not task:
|
||||
task_kw = {
|
||||
"project_id": project.id,
|
||||
"name": issue_key,
|
||||
"description": summary,
|
||||
"status": mapped_status,
|
||||
}
|
||||
if getattr(Task, "notes", None) is not None:
|
||||
task_kw["notes"] = description_text
|
||||
if self.integration.user_id is not None:
|
||||
task_kw["created_by"] = self.integration.user_id
|
||||
task = Task(**task_kw)
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name=issue_key[:200],
|
||||
description=desc or None,
|
||||
status=mapped_status,
|
||||
created_by=actor_id,
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
else:
|
||||
task.description = summary
|
||||
task.description = desc or None
|
||||
task.status = mapped_status
|
||||
if hasattr(task, "notes"):
|
||||
task.notes = description_text
|
||||
task.name = issue_key[:200]
|
||||
|
||||
if hasattr(task, "metadata"):
|
||||
if not task.metadata:
|
||||
task.metadata = {}
|
||||
task.metadata["jira_issue_key"] = issue_key
|
||||
task.metadata["jira_issue_id"] = issue.get("id")
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="jira",
|
||||
ref=issue_key,
|
||||
extra={"jira_issue_id": issue.get("id")},
|
||||
)
|
||||
|
||||
return 1
|
||||
|
||||
@@ -243,6 +254,13 @@ class JiraConnector(BaseConnector):
|
||||
if not token:
|
||||
return {"success": False, "message": "No access token available"}
|
||||
|
||||
from app.utils.integration_sync_context import require_sync_context
|
||||
|
||||
try:
|
||||
actor_id, client_id = require_sync_context(self.integration)
|
||||
except ValueError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net")
|
||||
api_url = f"{base_url}/rest/api/3/search"
|
||||
|
||||
@@ -273,7 +291,7 @@ class JiraConnector(BaseConnector):
|
||||
|
||||
for issue in issues:
|
||||
try:
|
||||
synced_count += self._upsert_task_from_issue(issue)
|
||||
synced_count += self._upsert_task_from_issue(issue, actor_id, client_id)
|
||||
except Exception as e:
|
||||
errors.append(f"Error syncing issue {issue.get('key', 'unknown')}: {str(e)}")
|
||||
|
||||
@@ -309,6 +327,13 @@ class JiraConnector(BaseConnector):
|
||||
if not token:
|
||||
return {"success": False, "message": "No access token available", "issue_key": issue_key}
|
||||
|
||||
from app.utils.integration_sync_context import require_sync_context
|
||||
|
||||
try:
|
||||
actor_id, client_id = require_sync_context(self.integration)
|
||||
except ValueError as e:
|
||||
return {"success": False, "message": str(e), "issue_key": issue_key}
|
||||
|
||||
base_url = self.integration.config.get("jira_url", "https://your-domain.atlassian.net")
|
||||
api_url = f"{base_url}/rest/api/3/issue/{issue_key}"
|
||||
fields = "summary,description,status,assignee,project,created,updated"
|
||||
@@ -337,7 +362,7 @@ class JiraConnector(BaseConnector):
|
||||
}
|
||||
|
||||
issue = response.json()
|
||||
self._upsert_task_from_issue(issue)
|
||||
self._upsert_task_from_issue(issue, actor_id, client_id)
|
||||
db.session.commit()
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Linear integration: import issues as tasks using a Personal API Key.
|
||||
|
||||
https://developers.linear.app/docs/graphql/working-with-the-graphql-api
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.integrations.base import BaseConnector
|
||||
from app.utils.integration_http import integration_session, session_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
LINEAR_GRAPHQL = "https://api.linear.app/graphql"
|
||||
|
||||
|
||||
class LinearConnector(BaseConnector):
|
||||
"""Linear connector (API key; issues → tasks)."""
|
||||
|
||||
display_name = "Linear"
|
||||
description = "Import Linear issues as tasks"
|
||||
icon = "tasks"
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "linear"
|
||||
|
||||
def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
|
||||
raise NotImplementedError("Linear uses a Personal API key; configure in Integrations.")
|
||||
|
||||
def exchange_code_for_tokens(self, code: str, redirect_uri: str) -> Dict[str, Any]:
|
||||
raise NotImplementedError("Linear uses a Personal API key.")
|
||||
|
||||
def refresh_access_token(self) -> Dict[str, Any]:
|
||||
raise NotImplementedError("Linear API keys do not expire.")
|
||||
|
||||
def _api_key(self) -> Optional[str]:
|
||||
if self.credentials and self.credentials.access_token:
|
||||
return self.credentials.access_token.strip()
|
||||
return None
|
||||
|
||||
def _graphql(self, query: str, variables: Optional[Dict] = None) -> Dict[str, Any]:
|
||||
key = self._api_key()
|
||||
if not key:
|
||||
raise ValueError("No Linear API key configured.")
|
||||
session = integration_session()
|
||||
resp = session_request(
|
||||
session,
|
||||
"POST",
|
||||
LINEAR_GRAPHQL,
|
||||
headers={"Authorization": key, "Content-Type": "application/json"},
|
||||
json={"query": query, "variables": variables or {}},
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
raise ValueError(f"Linear API HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
data = resp.json()
|
||||
if data.get("errors"):
|
||||
raise ValueError(f"Linear GraphQL error: {data['errors'][:1]}")
|
||||
return data.get("data") or {}
|
||||
|
||||
def test_connection(self) -> Dict[str, Any]:
|
||||
try:
|
||||
data = self._graphql("query { viewer { id name } }")
|
||||
viewer = data.get("viewer") or {}
|
||||
name = viewer.get("name") or viewer.get("id") or "OK"
|
||||
return {"success": True, "message": f"Connected to Linear as {name}."}
|
||||
except Exception as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import (
|
||||
ensure_project_integration_fields,
|
||||
find_project_by_integration_ref,
|
||||
find_task_by_integration_ref,
|
||||
require_sync_context,
|
||||
set_task_integration_ref,
|
||||
)
|
||||
|
||||
key = self._api_key()
|
||||
if not key:
|
||||
return {"success": False, "message": "No Linear API key. Save your key under Integrations → Linear."}
|
||||
|
||||
team_filter = (self.integration.config or {}).get("linear_team_keys", "")
|
||||
team_keys: Optional[List[str]] = None
|
||||
if team_filter and isinstance(team_filter, str):
|
||||
team_keys = [t.strip() for t in team_filter.split(",") if t.strip()]
|
||||
|
||||
try:
|
||||
actor_id, client_id = require_sync_context(self.integration)
|
||||
except ValueError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
q = """
|
||||
query SyncIssues($after: String) {
|
||||
issues(first: 100, after: $after) {
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
id
|
||||
identifier
|
||||
title
|
||||
url
|
||||
team { key name }
|
||||
state { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
all_nodes: List[Dict] = []
|
||||
after = None
|
||||
try:
|
||||
for _ in range(20):
|
||||
data = self._graphql(q, {"after": after})
|
||||
conn = (data.get("issues") or {})
|
||||
nodes = conn.get("nodes") or []
|
||||
for n in nodes:
|
||||
tk = (n.get("team") or {}).get("key") or ""
|
||||
if team_keys and tk not in team_keys:
|
||||
continue
|
||||
all_nodes.append(n)
|
||||
page = conn.get("pageInfo") or {}
|
||||
if not page.get("hasNextPage"):
|
||||
break
|
||||
after = page.get("endCursor")
|
||||
except Exception as e:
|
||||
logger.error("Linear sync fetch failed: %s", e, exc_info=True)
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
synced = 0
|
||||
errors: List[str] = []
|
||||
projects_cache: Dict[str, Project] = {}
|
||||
|
||||
def project_for_team(team_key: str, team_name: str) -> Optional[Project]:
|
||||
ref = f"{team_key}:{team_name}" if team_key else team_name or "default"
|
||||
if ref in projects_cache:
|
||||
return projects_cache[ref]
|
||||
p = find_project_by_integration_ref(client_id, "linear", ref)
|
||||
if not p:
|
||||
display = f"Linear / {team_name or team_key or 'Issues'}"
|
||||
p = Project.query.filter_by(client_id=client_id, name=display).first()
|
||||
if not p:
|
||||
try:
|
||||
p = Project(
|
||||
name=f"Linear / {team_name or team_key or 'Issues'}",
|
||||
client_id=client_id,
|
||||
description=f"Linear workspace team {team_key or '—'}",
|
||||
status="active",
|
||||
)
|
||||
db.session.add(p)
|
||||
db.session.flush()
|
||||
except Exception as ex:
|
||||
errors.append(f"Project create: {ex}")
|
||||
return None
|
||||
ensure_project_integration_fields(
|
||||
project=p,
|
||||
source="linear",
|
||||
ref=ref,
|
||||
display_name=p.name,
|
||||
description=p.description or "",
|
||||
)
|
||||
projects_cache[ref] = p
|
||||
return p
|
||||
|
||||
for n in all_nodes:
|
||||
issue_id = n.get("id")
|
||||
if not issue_id:
|
||||
continue
|
||||
team = n.get("team") or {}
|
||||
tk = team.get("key") or "unknown"
|
||||
tn = team.get("name") or tk
|
||||
project = project_for_team(tk, tn)
|
||||
if not project:
|
||||
continue
|
||||
title = (n.get("title") or "Untitled").strip()[:500]
|
||||
ident = n.get("identifier") or issue_id
|
||||
try:
|
||||
task = find_task_by_integration_ref(project.id, issue_id, source="linear")
|
||||
state_name = (n.get("state") or {}).get("name") or ""
|
||||
status = "done" if state_name.lower() in ("done", "completed", "canceled", "cancelled") else "todo"
|
||||
if not task:
|
||||
task = Task(
|
||||
name=f"{ident}: {title}"[:500],
|
||||
description=(n.get("url") or "")[:2000],
|
||||
project_id=project.id,
|
||||
status=status,
|
||||
created_by=actor_id,
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="linear",
|
||||
ref=issue_id,
|
||||
extra={"identifier": ident, "url": n.get("url")},
|
||||
)
|
||||
synced += 1
|
||||
else:
|
||||
task.name = f"{ident}: {title}"[:500]
|
||||
task.status = status
|
||||
if n.get("url"):
|
||||
task.description = (n.get("url") or "")[:2000]
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="linear",
|
||||
ref=issue_id,
|
||||
extra={"identifier": ident, "url": n.get("url")},
|
||||
)
|
||||
synced += 1
|
||||
except Exception as ex:
|
||||
errors.append(f"{ident}: {ex}")
|
||||
logger.warning("Linear issue upsert failed: %s", ex, exc_info=True)
|
||||
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return {"success": False, "message": f"Database error: {e}"}
|
||||
|
||||
msg = f"Processed {len(all_nodes)} Linear issues."
|
||||
if errors:
|
||||
msg += f" ({len(errors)} errors)"
|
||||
return {
|
||||
"success": True,
|
||||
"message": msg,
|
||||
"synced_items": synced,
|
||||
"synced_count": synced,
|
||||
"errors": errors[:20],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_config_schema(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"fields": [
|
||||
{
|
||||
"name": "linear_team_keys",
|
||||
"label": "Team keys (optional)",
|
||||
"type": "text",
|
||||
"description": "Comma-separated Linear team keys to import (empty = all teams)",
|
||||
"required": False,
|
||||
},
|
||||
{
|
||||
"name": "auto_sync",
|
||||
"label": "Automatic sync",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -109,8 +109,8 @@ class QuickBooksConnector(BaseConnector):
|
||||
)
|
||||
if company_response:
|
||||
company_info = company_response.get("CompanyInfo", {})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug("QuickBooks company info fetch after OAuth failed (optional): %s", e)
|
||||
|
||||
return {
|
||||
"access_token": data.get("access_token"),
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.integrations.github import GitHubConnector
|
||||
from app.integrations.gitlab import GitLabConnector
|
||||
from app.integrations.google_calendar import GoogleCalendarConnector
|
||||
from app.integrations.jira import JiraConnector
|
||||
from app.integrations.linear import LinearConnector
|
||||
from app.integrations.microsoft_teams import MicrosoftTeamsConnector
|
||||
from app.integrations.outlook_calendar import OutlookCalendarConnector
|
||||
from app.integrations.quickbooks import QuickBooksConnector
|
||||
@@ -22,6 +23,7 @@ from app.services.integration_service import IntegrationService
|
||||
def register_connectors():
|
||||
"""Register all available connectors."""
|
||||
IntegrationService.register_connector("jira", JiraConnector)
|
||||
IntegrationService.register_connector("linear", LinearConnector)
|
||||
IntegrationService.register_connector("slack", SlackConnector)
|
||||
IntegrationService.register_connector("github", GitHubConnector)
|
||||
IntegrationService.register_connector("google_calendar", GoogleCalendarConnector)
|
||||
|
||||
+82
-53
@@ -121,8 +121,7 @@ class TrelloConnector(BaseConnector):
|
||||
|
||||
def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
|
||||
"""Sync boards and cards with Trello."""
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import require_sync_context
|
||||
|
||||
try:
|
||||
from app.models import Settings
|
||||
@@ -135,6 +134,11 @@ class TrelloConnector(BaseConnector):
|
||||
if not token or not api_key:
|
||||
return {"success": False, "message": "Trello credentials not configured"}
|
||||
|
||||
try:
|
||||
actor_id, client_id = require_sync_context(self.integration)
|
||||
except ValueError as e:
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Get sync direction from config
|
||||
sync_direction = (
|
||||
self.integration.config.get("sync_direction", "trello_to_timetracker")
|
||||
@@ -143,10 +147,10 @@ class TrelloConnector(BaseConnector):
|
||||
)
|
||||
|
||||
if sync_direction in ("trello_to_timetracker", "bidirectional"):
|
||||
trello_result = self._sync_trello_to_timetracker(api_key, token)
|
||||
trello_result = self._sync_trello_to_timetracker(api_key, token, actor_id, client_id)
|
||||
# If bidirectional, also sync TimeTracker to Trello
|
||||
if sync_direction == "bidirectional":
|
||||
tracker_result = self._sync_timetracker_to_trello(api_key, token)
|
||||
tracker_result = self._sync_timetracker_to_trello(api_key, token, actor_id, client_id)
|
||||
# Merge results
|
||||
if trello_result.get("success") and tracker_result.get("success"):
|
||||
return {
|
||||
@@ -169,17 +173,25 @@ class TrelloConnector(BaseConnector):
|
||||
|
||||
# Handle TimeTracker to Trello sync
|
||||
if sync_direction == "timetracker_to_trello":
|
||||
return self._sync_timetracker_to_trello(api_key, token)
|
||||
return self._sync_timetracker_to_trello(api_key, token, actor_id, client_id)
|
||||
|
||||
return {"success": False, "message": f"Unknown sync direction: {sync_direction}"}
|
||||
|
||||
except Exception as e:
|
||||
return {"success": False, "message": f"Sync failed: {str(e)}"}
|
||||
|
||||
def _sync_trello_to_timetracker(self, api_key: str, token: str) -> Dict[str, Any]:
|
||||
def _sync_trello_to_timetracker(
|
||||
self, api_key: str, token: str, actor_id: int, client_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Sync Trello boards and cards to TimeTracker projects and tasks."""
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import (
|
||||
ensure_project_integration_fields,
|
||||
find_project_by_integration_ref,
|
||||
find_task_by_integration_ref,
|
||||
set_task_integration_ref,
|
||||
)
|
||||
|
||||
synced_count = 0
|
||||
errors = []
|
||||
@@ -199,25 +211,31 @@ class TrelloConnector(BaseConnector):
|
||||
|
||||
for board in boards:
|
||||
try:
|
||||
# Create or update project from board
|
||||
project = Project.query.filter_by(user_id=self.integration.user_id, name=board.get("name")).first()
|
||||
board_id = str(board.get("id") or "")
|
||||
board_name = (board.get("name") or "Trello board").strip()[:200]
|
||||
if not board_id:
|
||||
continue
|
||||
|
||||
project = find_project_by_integration_ref(client_id, "trello", board_id)
|
||||
if not project:
|
||||
project = Project.query.filter_by(client_id=client_id, name=board_name).first()
|
||||
if not project:
|
||||
project = Project(
|
||||
name=board.get("name"),
|
||||
description=board.get("desc", ""),
|
||||
user_id=self.integration.user_id,
|
||||
name=board_name,
|
||||
client_id=client_id,
|
||||
description=(board.get("desc") or "") or None,
|
||||
status="active",
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
ensure_project_integration_fields(
|
||||
project,
|
||||
source="trello",
|
||||
ref=board_id,
|
||||
display_name=board_name,
|
||||
description=(board.get("desc") or "") or None,
|
||||
)
|
||||
|
||||
# Store Trello board ID in metadata
|
||||
if not hasattr(project, "metadata") or not project.metadata:
|
||||
project.metadata = {}
|
||||
project.metadata["trello_board_id"] = board.get("id")
|
||||
|
||||
# Sync cards as tasks
|
||||
cards_response = requests.get(
|
||||
f"{self.BASE_URL}/boards/{board.get('id')}/cards",
|
||||
params={"key": api_key, "token": token, "filter": "open"},
|
||||
@@ -227,32 +245,34 @@ class TrelloConnector(BaseConnector):
|
||||
cards = cards_response.json()
|
||||
|
||||
for card in cards:
|
||||
# Find or create task
|
||||
task = Task.query.filter_by(project_id=project.id, name=card.get("name")).first()
|
||||
|
||||
card_id = str(card.get("id") or "")
|
||||
if not card_id:
|
||||
continue
|
||||
cname = (card.get("name") or "Card").strip()[:200]
|
||||
new_status = self._map_trello_list_to_status(card.get("idList"))
|
||||
task = find_task_by_integration_ref(project.id, card_id, source="trello")
|
||||
if not task:
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name=card.get("name"),
|
||||
description=card.get("desc", ""),
|
||||
status=self._map_trello_list_to_status(card.get("idList")),
|
||||
name=cname,
|
||||
description=(card.get("desc") or "") or None,
|
||||
status=new_status,
|
||||
created_by=actor_id,
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
else:
|
||||
# Update existing task if needed
|
||||
if card.get("desc") and task.description != card.get("desc"):
|
||||
task.description = card.get("desc")
|
||||
# Update status based on list
|
||||
new_status = self._map_trello_list_to_status(card.get("idList"))
|
||||
if task.status != new_status:
|
||||
task.status = new_status
|
||||
if card.get("desc") is not None:
|
||||
task.description = (card.get("desc") or "") or None
|
||||
task.name = cname
|
||||
task.status = new_status
|
||||
|
||||
# Store Trello card ID in metadata
|
||||
if not hasattr(task, "metadata") or not task.metadata:
|
||||
task.metadata = {}
|
||||
task.metadata["trello_card_id"] = card.get("id")
|
||||
task.metadata["trello_list_id"] = card.get("idList")
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="trello",
|
||||
ref=card_id,
|
||||
extra={"trello_list_id": card.get("idList")},
|
||||
)
|
||||
|
||||
synced_count += 1
|
||||
except Exception as e:
|
||||
@@ -262,22 +282,25 @@ class TrelloConnector(BaseConnector):
|
||||
|
||||
return {"success": True, "synced_count": synced_count, "errors": errors}
|
||||
|
||||
def _sync_timetracker_to_trello(self, api_key: str, token: str) -> Dict[str, Any]:
|
||||
def _sync_timetracker_to_trello(
|
||||
self, api_key: str, token: str, actor_id: int, client_id: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Sync TimeTracker tasks to Trello cards."""
|
||||
from app import db
|
||||
from app.models import Project, Task
|
||||
from app.utils.integration_sync_context import ensure_project_integration_fields, set_task_integration_ref
|
||||
|
||||
synced_count = 0
|
||||
errors = []
|
||||
|
||||
# Get all projects that have Trello board IDs
|
||||
projects = Project.query.filter_by(user_id=self.integration.user_id, status="active").all()
|
||||
projects = Project.query.filter_by(client_id=client_id, status="active").all()
|
||||
|
||||
for project in projects:
|
||||
# Check if project has Trello board ID
|
||||
cf = project.custom_fields if isinstance(project.custom_fields, dict) else {}
|
||||
block = cf.get("integration") if isinstance(cf, dict) else {}
|
||||
trello_board_id = None
|
||||
if hasattr(project, "metadata") and project.metadata:
|
||||
trello_board_id = project.metadata.get("trello_board_id")
|
||||
if isinstance(block, dict) and block.get("source") == "trello":
|
||||
trello_board_id = block.get("ref")
|
||||
|
||||
if not trello_board_id:
|
||||
# Try to find or create board
|
||||
@@ -305,9 +328,13 @@ class TrelloConnector(BaseConnector):
|
||||
continue
|
||||
|
||||
if trello_board_id:
|
||||
if not hasattr(project, "metadata") or not project.metadata:
|
||||
project.metadata = {}
|
||||
project.metadata["trello_board_id"] = trello_board_id
|
||||
ensure_project_integration_fields(
|
||||
project,
|
||||
source="trello",
|
||||
ref=str(trello_board_id),
|
||||
display_name=project.name,
|
||||
description=project.description,
|
||||
)
|
||||
|
||||
if not trello_board_id:
|
||||
continue
|
||||
@@ -344,10 +371,11 @@ class TrelloConnector(BaseConnector):
|
||||
|
||||
for task in tasks:
|
||||
try:
|
||||
# Check if task already has Trello card ID
|
||||
tcf = task.custom_fields if isinstance(task.custom_fields, dict) else {}
|
||||
tblock = tcf.get("integration") if isinstance(tcf, dict) else {}
|
||||
trello_card_id = None
|
||||
if hasattr(task, "metadata") and task.metadata:
|
||||
trello_card_id = task.metadata.get("trello_card_id")
|
||||
if isinstance(tblock, dict) and tblock.get("source") == "trello":
|
||||
trello_card_id = tblock.get("ref")
|
||||
|
||||
# Determine target list
|
||||
target_list_id = status_to_list.get(task.status, default_list_id)
|
||||
@@ -386,11 +414,12 @@ class TrelloConnector(BaseConnector):
|
||||
card_data = create_response.json()
|
||||
trello_card_id = card_data.get("id")
|
||||
|
||||
# Store Trello card ID in task metadata
|
||||
if not hasattr(task, "metadata") or not task.metadata:
|
||||
task.metadata = {}
|
||||
task.metadata["trello_card_id"] = trello_card_id
|
||||
task.metadata["trello_list_id"] = target_list_id
|
||||
set_task_integration_ref(
|
||||
task,
|
||||
source="trello",
|
||||
ref=str(trello_card_id),
|
||||
extra={"trello_list_id": target_list_id},
|
||||
)
|
||||
|
||||
synced_count += 1
|
||||
else:
|
||||
@@ -427,7 +456,7 @@ class TrelloConnector(BaseConnector):
|
||||
|
||||
# Map common list names to statuses
|
||||
if "done" in list_name or "completed" in list_name or "closed" in list_name:
|
||||
return "completed"
|
||||
return "done"
|
||||
elif "in progress" in list_name or "doing" in list_name or "active" in list_name:
|
||||
return "in_progress"
|
||||
elif "todo" in list_name or "to do" in list_name or "backlog" in list_name:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from .activity import Activity
|
||||
from .api_idempotency_key import ApiIdempotencyKey
|
||||
from .api_token import ApiToken
|
||||
from .audit_log import AuditLog
|
||||
from .budget_alert import BudgetAlert
|
||||
@@ -132,6 +133,7 @@ __all__ = [
|
||||
"Expense",
|
||||
"Permission",
|
||||
"Role",
|
||||
"ApiIdempotencyKey",
|
||||
"ApiToken",
|
||||
"CalendarEvent",
|
||||
"BudgetAlert",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Idempotency keys for API write deduplication (e.g. mobile retries)."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class ApiIdempotencyKey(db.Model):
|
||||
"""Stores completed idempotent API responses per token + scope + key hash."""
|
||||
|
||||
__tablename__ = "api_idempotency_keys"
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
api_token_id = db.Column(db.Integer, db.ForeignKey("api_tokens.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
scope = db.Column(db.String(128), nullable=False)
|
||||
key_hash = db.Column(db.String(64), nullable=False)
|
||||
response_status = db.Column(db.Integer, nullable=False)
|
||||
response_body = db.Column(db.Text, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint("api_token_id", "scope", "key_hash", name="uq_api_idempotency_token_scope_key"),
|
||||
)
|
||||
+83
-7
@@ -55,6 +55,8 @@ class Quote(db.Model):
|
||||
approved_at = db.Column(db.DateTime, nullable=True)
|
||||
rejection_reason = db.Column(db.Text, nullable=True)
|
||||
rejected_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=True)
|
||||
requires_approval = db.Column(db.Boolean, default=False, nullable=False)
|
||||
approval_level = db.Column(db.Integer, nullable=False, default=1)
|
||||
|
||||
# Client portal visibility
|
||||
visible_to_client = db.Column(
|
||||
@@ -93,7 +95,13 @@ class Quote(db.Model):
|
||||
accepter = db.relationship("User", foreign_keys=[accepted_by], backref="accepted_quotes")
|
||||
approver = db.relationship("User", foreign_keys=[approved_by], backref="approved_quotes")
|
||||
rejecter = db.relationship("User", foreign_keys=[rejected_by], backref="rejected_quotes")
|
||||
items = db.relationship("QuoteItem", backref="quote", lazy="selectin", cascade="all, delete-orphan")
|
||||
items = db.relationship(
|
||||
"QuoteItem",
|
||||
backref="quote",
|
||||
lazy="selectin",
|
||||
cascade="all, delete-orphan",
|
||||
order_by="QuoteItem.position, QuoteItem.id",
|
||||
)
|
||||
template = db.relationship("QuotePDFTemplate", backref="quotes", lazy="joined")
|
||||
|
||||
def __init__(self, quote_number, client_id, title, created_by, **kwargs):
|
||||
@@ -123,6 +131,9 @@ class Quote(db.Model):
|
||||
self.discount_reason = kwargs.get("discount_reason", "").strip() if kwargs.get("discount_reason") else None
|
||||
self.coupon_code = kwargs.get("coupon_code", "").strip().upper() if kwargs.get("coupon_code") else None
|
||||
|
||||
self.requires_approval = bool(kwargs.get("requires_approval", False))
|
||||
self.approval_level = int(kwargs.get("approval_level", 1) or 1)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Quote {self.quote_number} ({self.title})>"
|
||||
|
||||
@@ -163,6 +174,15 @@ class Quote(db.Model):
|
||||
"""Check if quote has been converted to a project"""
|
||||
return self.project_id is not None
|
||||
|
||||
@property
|
||||
def can_be_sent(self):
|
||||
"""Draft quotes can be sent if approval is not required or already approved."""
|
||||
if self.status != "draft":
|
||||
return False
|
||||
if not self.requires_approval:
|
||||
return True
|
||||
return self.approval_status == "approved"
|
||||
|
||||
def calculate_totals(self):
|
||||
"""Calculate quote totals from items, applying discount if any"""
|
||||
items_total = sum(item.total_amount for item in self.items)
|
||||
@@ -405,28 +425,78 @@ class QuoteItem(db.Model):
|
||||
# Optional fields
|
||||
unit = db.Column(db.String(20), nullable=True) # 'hours', 'days', 'items', etc.
|
||||
|
||||
# Inventory integration
|
||||
# Line classification (issue #585): item | expense | good
|
||||
line_kind = db.Column(db.String(20), nullable=False, default="item")
|
||||
display_name = db.Column(db.String(200), nullable=True)
|
||||
category = db.Column(db.String(50), nullable=True)
|
||||
line_date = db.Column(db.Date, nullable=True)
|
||||
sku = db.Column(db.String(100), nullable=True)
|
||||
|
||||
# Inventory integration (only for line_kind == "item")
|
||||
stock_item_id = db.Column(db.Integer, db.ForeignKey("stock_items.id"), nullable=True, index=True)
|
||||
warehouse_id = db.Column(db.Integer, db.ForeignKey("warehouses.id"), nullable=True)
|
||||
is_stock_item = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
# Metadata
|
||||
position = db.Column(db.Integer, nullable=False, default=0)
|
||||
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
stock_item = db.relationship("StockItem", foreign_keys=[stock_item_id], lazy="joined")
|
||||
warehouse = db.relationship("Warehouse", foreign_keys=[warehouse_id], lazy="joined")
|
||||
|
||||
def __init__(self, quote_id, description, quantity, unit_price, unit=None, stock_item_id=None, warehouse_id=None):
|
||||
def __init__(
|
||||
self,
|
||||
quote_id,
|
||||
description,
|
||||
quantity,
|
||||
unit_price,
|
||||
unit=None,
|
||||
stock_item_id=None,
|
||||
warehouse_id=None,
|
||||
position=0,
|
||||
line_kind="item",
|
||||
display_name=None,
|
||||
category=None,
|
||||
line_date=None,
|
||||
sku=None,
|
||||
):
|
||||
self.quote_id = quote_id
|
||||
self.description = description.strip()
|
||||
kind = (line_kind or "item").strip() or "item"
|
||||
if kind not in ("item", "expense", "good"):
|
||||
kind = "item"
|
||||
self.line_kind = kind
|
||||
|
||||
dn = display_name.strip() if display_name else None
|
||||
cat = category.strip() if category else None
|
||||
sk = sku.strip() if sku else None
|
||||
|
||||
self.display_name = dn if kind != "item" else None
|
||||
self.category = cat if kind != "item" else None
|
||||
self.line_date = line_date if kind == "expense" else None
|
||||
self.sku = sk if kind == "good" else None
|
||||
|
||||
desc = (description or "").strip()
|
||||
if kind == "item":
|
||||
self.description = desc or "-"
|
||||
else:
|
||||
self.description = desc if desc else (dn or "-")
|
||||
|
||||
self.quantity = Decimal(str(quantity))
|
||||
self.unit_price = Decimal(str(unit_price))
|
||||
self.total_amount = self.quantity * self.unit_price
|
||||
self.unit = unit.strip() if unit else None
|
||||
self.stock_item_id = stock_item_id
|
||||
self.warehouse_id = warehouse_id
|
||||
self.is_stock_item = stock_item_id is not None
|
||||
|
||||
if kind != "item":
|
||||
self.stock_item_id = None
|
||||
self.warehouse_id = None
|
||||
self.is_stock_item = False
|
||||
else:
|
||||
self.stock_item_id = stock_item_id
|
||||
self.warehouse_id = warehouse_id
|
||||
self.is_stock_item = stock_item_id is not None
|
||||
|
||||
self.position = int(position) if position is not None else 0
|
||||
|
||||
def __repr__(self):
|
||||
return f"<QuoteItem {self.description} ({self.quantity} @ {self.unit_price})>"
|
||||
@@ -436,6 +506,11 @@ class QuoteItem(db.Model):
|
||||
return {
|
||||
"id": self.id,
|
||||
"quote_id": self.quote_id,
|
||||
"line_kind": self.line_kind,
|
||||
"display_name": self.display_name,
|
||||
"category": self.category,
|
||||
"line_date": self.line_date.isoformat() if self.line_date else None,
|
||||
"sku": self.sku,
|
||||
"description": self.description,
|
||||
"quantity": float(self.quantity),
|
||||
"unit_price": float(self.unit_price),
|
||||
@@ -444,6 +519,7 @@ class QuoteItem(db.Model):
|
||||
"stock_item_id": self.stock_item_id,
|
||||
"warehouse_id": self.warehouse_id,
|
||||
"is_stock_item": self.is_stock_item,
|
||||
"position": self.position,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -124,13 +124,14 @@ class QuoteTemplate(db.Model):
|
||||
|
||||
from app.models import QuoteItem
|
||||
|
||||
for item_data in items:
|
||||
for position, item_data in enumerate(items):
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=item_data.get("description", ""),
|
||||
quantity=Decimal(str(item_data.get("quantity", 1))),
|
||||
unit_price=Decimal(str(item_data.get("unit_price", 0))),
|
||||
unit=item_data.get("unit"),
|
||||
position=position,
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
|
||||
+5
-18
@@ -630,25 +630,12 @@ class Settings(db.Model):
|
||||
pass
|
||||
|
||||
# Fallback: return a non-persisted Settings instance
|
||||
# #region agent log
|
||||
try:
|
||||
import json
|
||||
import logging
|
||||
|
||||
log_data = {
|
||||
"location": "settings.py:493",
|
||||
"message": "Returning fallback Settings instance",
|
||||
"data": {"fallback": True},
|
||||
"timestamp": int(datetime.utcnow().timestamp() * 1000),
|
||||
"sessionId": "debug-session",
|
||||
"runId": "run1",
|
||||
"hypothesisId": "E",
|
||||
}
|
||||
log_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".cursor", "debug.log")
|
||||
with open(log_path, "a", encoding="utf-8") as f:
|
||||
f.write(json.dumps(log_data) + "\n")
|
||||
except (OSError, IOError, TypeError, ValueError):
|
||||
pass
|
||||
# #endregion
|
||||
logging.getLogger(__name__).warning(
|
||||
"Returning transient in-memory Settings instance (database row missing or creation failed). "
|
||||
"Check database connectivity and migrations."
|
||||
)
|
||||
return cls()
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -29,6 +29,8 @@ class Task(db.Model):
|
||||
color = db.Column(db.String(7), nullable=True)
|
||||
# Comma-separated tags for categorization
|
||||
tags = db.Column(db.String(500), nullable=True)
|
||||
# Integration metadata (e.g. GitHub issue id); see app.utils.integration_sync_context
|
||||
custom_fields = db.Column(db.JSON, nullable=True)
|
||||
|
||||
# Relationships
|
||||
# project relationship is defined via backref in Project model
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
sRGB-v2-nano.icc is from Compact-ICC-Profiles by Liam R. E. Quin / saucecontrol,
|
||||
distributed under the MIT License.
|
||||
Source: https://github.com/saucecontrol/Compact-ICC-Profiles
|
||||
Binary file not shown.
+12
-59
@@ -947,6 +947,8 @@ def create_entry():
|
||||
@login_required
|
||||
def bulk_entries_action():
|
||||
"""Perform bulk actions on time entries: delete, set billable, set paid, add/remove tag."""
|
||||
from app.services.time_entry_bulk_service import apply_bulk_time_entry_actions
|
||||
|
||||
data = request.get_json() or {}
|
||||
entry_ids = data.get("entry_ids") or []
|
||||
action = (data.get("action") or "").strip()
|
||||
@@ -954,66 +956,17 @@ def bulk_entries_action():
|
||||
|
||||
if not entry_ids or not isinstance(entry_ids, list):
|
||||
return jsonify({"error": "entry_ids must be a non-empty list"}), 400
|
||||
if action not in {"delete", "set_billable", "set_paid", "add_tag", "remove_tag"}:
|
||||
return jsonify({"error": "Unsupported action"}), 400
|
||||
try:
|
||||
ids_int = [int(eid) for eid in entry_ids]
|
||||
except (TypeError, ValueError):
|
||||
return jsonify({"error": "entry_ids must be integers"}), 400
|
||||
|
||||
# Load entries with permission checks
|
||||
q = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids))
|
||||
entries = q.all()
|
||||
if not entries:
|
||||
return jsonify({"error": "No entries found"}), 404
|
||||
|
||||
# Permission: non-admins can only modify own entries
|
||||
if not current_user.is_admin:
|
||||
for e in entries:
|
||||
if e.user_id != current_user.id:
|
||||
return jsonify({"error": "Access denied for one or more entries"}), 403
|
||||
|
||||
affected = 0
|
||||
if action == "delete":
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
db.session.delete(e)
|
||||
affected += 1
|
||||
elif action == "set_billable":
|
||||
flag = bool(value)
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
e.billable = flag
|
||||
e.updated_at = local_now()
|
||||
affected += 1
|
||||
elif action == "set_paid":
|
||||
flag = bool(value)
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
e.set_paid(flag)
|
||||
affected += 1
|
||||
elif action in {"add_tag", "remove_tag"}:
|
||||
tag = (value or "").strip()
|
||||
if not tag:
|
||||
return jsonify({"error": "Tag value is required"}), 400
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
tags = set(e.tag_list)
|
||||
if action == "add_tag":
|
||||
tags.add(tag)
|
||||
else:
|
||||
tags.discard(tag)
|
||||
e.tags = ", ".join(sorted(tags)) if tags else None
|
||||
e.updated_at = local_now()
|
||||
affected += 1
|
||||
|
||||
if affected > 0:
|
||||
if not safe_commit("api_bulk_entries", {"action": action, "count": affected}):
|
||||
return jsonify({"error": "Database error during bulk operation"}), 500
|
||||
else:
|
||||
db.session.rollback()
|
||||
|
||||
return jsonify({"success": True, "affected": affected})
|
||||
result = apply_bulk_time_entry_actions(
|
||||
ids_int, action, value, user_id=current_user.id, is_admin=current_user.is_admin
|
||||
)
|
||||
if not result.get("success"):
|
||||
return jsonify({"error": result.get("error", "Bulk operation failed")}), result.get("http_status", 400)
|
||||
return jsonify({"success": True, "affected": result.get("affected", 0)})
|
||||
|
||||
|
||||
@api_bp.route("/api/calendar/events")
|
||||
|
||||
@@ -28,14 +28,17 @@ try:
|
||||
api_bp = api_legacy_module.api_bp
|
||||
else:
|
||||
raise ImportError("Could not load api.py module")
|
||||
except Exception as e:
|
||||
# Last resort: create a dummy blueprint to prevent import errors
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
except Exception:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Could not import api_bp from api.py: {e}. Using dummy blueprint.")
|
||||
logger.exception("Could not import legacy api_bp from app.routes.api.py")
|
||||
if os.getenv("ALLOW_DUMMY_LEGACY_API_BLUEPRINT", "").strip().lower() in ("1", "true", "yes"):
|
||||
from flask import Blueprint
|
||||
|
||||
api_bp = Blueprint("api", __name__)
|
||||
logger.warning("ALLOW_DUMMY_LEGACY_API_BLUEPRINT is set; legacy /api routes are disabled.")
|
||||
else:
|
||||
raise
|
||||
|
||||
__all__ = ["api_v1_bp", "api_bp"]
|
||||
|
||||
+46
-3
@@ -1359,7 +1359,6 @@ def create_quote():
|
||||
tags:
|
||||
- Quotes
|
||||
"""
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from app.models import QuoteItem
|
||||
@@ -1399,13 +1398,35 @@ def create_quote():
|
||||
|
||||
# Add items
|
||||
items = data.get("items", [])
|
||||
for item_data in items:
|
||||
for position, item_data in enumerate(items):
|
||||
kind = (item_data.get("line_kind") or "item").strip() or "item"
|
||||
if kind not in ("item", "expense", "good"):
|
||||
kind = "item"
|
||||
sid = item_data.get("stock_item_id")
|
||||
wid = item_data.get("warehouse_id")
|
||||
try:
|
||||
stock_item_id = int(sid) if sid is not None and str(sid).strip() != "" else None
|
||||
except (TypeError, ValueError):
|
||||
stock_item_id = None
|
||||
try:
|
||||
warehouse_id = int(wid) if wid is not None and str(wid).strip() != "" else None
|
||||
except (TypeError, ValueError):
|
||||
warehouse_id = None
|
||||
line_dt = _parse_date(item_data.get("line_date")) if item_data.get("line_date") else None
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=item_data.get("description", ""),
|
||||
quantity=Decimal(str(item_data.get("quantity", 1))),
|
||||
unit_price=Decimal(str(item_data.get("unit_price", 0))),
|
||||
unit=item_data.get("unit"),
|
||||
stock_item_id=stock_item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
position=position,
|
||||
line_kind=kind,
|
||||
display_name=item_data.get("display_name"),
|
||||
category=item_data.get("category"),
|
||||
line_date=line_dt,
|
||||
sku=item_data.get("sku"),
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
@@ -1468,13 +1489,35 @@ def update_quote(quote_id):
|
||||
db.session.delete(item)
|
||||
|
||||
# Add new items
|
||||
for item_data in data["items"]:
|
||||
for position, item_data in enumerate(data["items"]):
|
||||
kind = (item_data.get("line_kind") or "item").strip() or "item"
|
||||
if kind not in ("item", "expense", "good"):
|
||||
kind = "item"
|
||||
sid = item_data.get("stock_item_id")
|
||||
wid = item_data.get("warehouse_id")
|
||||
try:
|
||||
stock_item_id = int(sid) if sid is not None and str(sid).strip() != "" else None
|
||||
except (TypeError, ValueError):
|
||||
stock_item_id = None
|
||||
try:
|
||||
warehouse_id = int(wid) if wid is not None and str(wid).strip() != "" else None
|
||||
except (TypeError, ValueError):
|
||||
warehouse_id = None
|
||||
line_dt = _parse_date(item_data.get("line_date")) if item_data.get("line_date") else None
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=item_data.get("description", ""),
|
||||
quantity=Decimal(str(item_data.get("quantity", 1))),
|
||||
unit_price=Decimal(str(item_data.get("unit_price", 0))),
|
||||
unit=item_data.get("unit"),
|
||||
stock_item_id=stock_item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
position=position,
|
||||
line_kind=kind,
|
||||
display_name=item_data.get("display_name"),
|
||||
category=item_data.get("category"),
|
||||
line_date=line_dt,
|
||||
sku=item_data.get("sku"),
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
|
||||
@@ -88,12 +88,79 @@ def get_time_entry(entry_id):
|
||||
return jsonify({"time_entry": entry.to_dict()})
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries/import-csv", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def import_time_entries_csv():
|
||||
"""Import time entries from CSV (header row required)."""
|
||||
from app.services.time_entry_csv_import_service import import_time_entries_from_csv_text
|
||||
|
||||
csv_text = ""
|
||||
if request.files and request.files.get("file"):
|
||||
up = request.files["file"]
|
||||
csv_text = (up.read() or b"").decode("utf-8", errors="replace")
|
||||
elif request.is_json:
|
||||
data = request.get_json() or {}
|
||||
csv_text = (data.get("csv") or data.get("data") or "") or ""
|
||||
else:
|
||||
csv_text = request.get_data(as_text=True) or ""
|
||||
|
||||
result, status = import_time_entries_from_csv_text(
|
||||
csv_text, user_id=g.api_user.id, is_admin=g.api_user.is_admin
|
||||
)
|
||||
return jsonify(result), status
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries/bulk", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def bulk_time_entries():
|
||||
"""Bulk actions on time entries (same behavior as session /api/entries/bulk)."""
|
||||
from app.services.time_entry_bulk_service import apply_bulk_time_entry_actions
|
||||
|
||||
data = request.get_json() or {}
|
||||
entry_ids = data.get("entry_ids") or []
|
||||
action = (data.get("action") or "").strip()
|
||||
value = data.get("value")
|
||||
if not entry_ids or not isinstance(entry_ids, list):
|
||||
return validation_error_response(
|
||||
errors={"entry_ids": ["Must be a non-empty list of integer ids"]},
|
||||
message="Invalid entry_ids",
|
||||
)
|
||||
ids = []
|
||||
for eid in entry_ids:
|
||||
try:
|
||||
ids.append(int(eid))
|
||||
except (TypeError, ValueError):
|
||||
return validation_error_response(errors={"entry_ids": ["All entry ids must be integers"]})
|
||||
result = apply_bulk_time_entry_actions(
|
||||
ids, action, value, user_id=g.api_user.id, is_admin=g.api_user.is_admin
|
||||
)
|
||||
if not result.get("success"):
|
||||
code = result.get("http_status", 400)
|
||||
return error_response(result.get("error", "Bulk operation failed"), status_code=code)
|
||||
return jsonify({"success": True, "affected": result.get("affected", 0)})
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries", methods=["POST"])
|
||||
@require_api_token("write:time_entries")
|
||||
def create_time_entry():
|
||||
"""Create a new time entry."""
|
||||
from app.services import TimeTrackingService
|
||||
|
||||
from app.utils.api_idempotency import (
|
||||
SCOPE_POST_TIME_ENTRY,
|
||||
lookup_idempotent_response,
|
||||
normalize_idempotency_key,
|
||||
replay_response,
|
||||
store_idempotent_response,
|
||||
)
|
||||
|
||||
idem_key = normalize_idempotency_key(request.headers.get("Idempotency-Key"))
|
||||
if idem_key:
|
||||
existing = lookup_idempotent_response(g.api_token.id, SCOPE_POST_TIME_ENTRY, idem_key)
|
||||
if existing:
|
||||
status_code, body_json = existing
|
||||
return replay_response(status_code, body_json)
|
||||
|
||||
data = request.get_json() or {}
|
||||
schema = TimeEntryCreateSchema()
|
||||
try:
|
||||
@@ -155,7 +222,23 @@ def create_time_entry():
|
||||
user_agent=user_agent,
|
||||
)
|
||||
|
||||
return jsonify({"message": "Time entry created successfully", "time_entry": result["entry"].to_dict()}), 201
|
||||
payload = {"message": "Time entry created successfully", "time_entry": result["entry"].to_dict()}
|
||||
resp = jsonify(payload)
|
||||
resp.status_code = 201
|
||||
if idem_key:
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app import db
|
||||
|
||||
try:
|
||||
store_idempotent_response(g.api_token.id, SCOPE_POST_TIME_ENTRY, idem_key, 201, payload)
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
existing = lookup_idempotent_response(g.api_token.id, SCOPE_POST_TIME_ENTRY, idem_key)
|
||||
if existing:
|
||||
status_code, body_json = existing
|
||||
return replay_response(status_code, body_json)
|
||||
return resp
|
||||
|
||||
|
||||
@api_v1_time_entries_bp.route("/time-entries/<int:entry_id>", methods=["PUT", "PATCH"])
|
||||
@@ -188,9 +271,16 @@ def update_time_entry(entry_id):
|
||||
paid=validated.get("paid"),
|
||||
invoice_number=validated.get("invoice_number"),
|
||||
reason=data.get("reason"),
|
||||
expected_updated_at=validated.get("if_updated_at"),
|
||||
)
|
||||
|
||||
if not result.get("success"):
|
||||
if result.get("error") == "conflict":
|
||||
return error_response(
|
||||
result.get("message", "Conflict"),
|
||||
error_code="conflict",
|
||||
status_code=409,
|
||||
)
|
||||
return error_response(
|
||||
result.get("message", "Could not update time entry"),
|
||||
status_code=400,
|
||||
|
||||
+37
-10
@@ -466,6 +466,34 @@ def manage_integration(provider):
|
||||
else:
|
||||
flash(_("Failed to update credentials."), "error")
|
||||
|
||||
elif request.form.get("action") == "update_linear_api_key":
|
||||
if provider != "linear":
|
||||
flash(_("Invalid action for this integration."), "error")
|
||||
return redirect(url_for("integrations.manage_integration", provider=provider))
|
||||
if not integration:
|
||||
flash(_("Integration not found."), "error")
|
||||
return redirect(url_for("integrations.manage_integration", provider=provider))
|
||||
api_key = request.form.get("linear_api_key", "").strip()
|
||||
if not api_key:
|
||||
flash(_("Linear API key is required."), "error")
|
||||
return redirect(url_for("integrations.manage_integration", provider=provider))
|
||||
result = service.save_credentials(
|
||||
integration_id=integration.id,
|
||||
access_token=api_key,
|
||||
refresh_token=None,
|
||||
expires_at=None,
|
||||
token_type="Bearer",
|
||||
scope="read",
|
||||
extra_data={"auth_type": "api_key"},
|
||||
)
|
||||
if result.get("success"):
|
||||
integration.is_active = True
|
||||
safe_commit("linear_api_key_saved", {"integration_id": integration.id})
|
||||
flash(_("Linear API key saved. Use Sync to import issues as tasks."), "success")
|
||||
else:
|
||||
flash(result.get("message", _("Could not save API key.")), "error")
|
||||
return redirect(url_for("integrations.manage_integration", provider=provider))
|
||||
|
||||
# Check if this is a CalDAV credential update (non-OAuth)
|
||||
elif request.form.get("action") == "update_caldav_credentials":
|
||||
# CalDAV uses username/password, not OAuth
|
||||
@@ -747,7 +775,7 @@ def view_integration(integration_id):
|
||||
recent_events = (
|
||||
IntegrationEvent.query.filter_by(integration_id=integration_id)
|
||||
.order_by(IntegrationEvent.created_at.desc())
|
||||
.limit(20)
|
||||
.limit(50)
|
||||
.all()
|
||||
)
|
||||
|
||||
@@ -879,18 +907,20 @@ def sync_integration(integration_id):
|
||||
return redirect(url_for("integrations.view_integration", integration_id=integration_id))
|
||||
|
||||
try:
|
||||
from app.utils.integration_sync_context import sync_result_item_count
|
||||
from datetime import datetime
|
||||
|
||||
sync_result = connector.sync_data()
|
||||
|
||||
# Update integration status
|
||||
from datetime import datetime
|
||||
|
||||
integration.last_sync_at = datetime.utcnow()
|
||||
if sync_result.get("success"):
|
||||
integration.last_sync_status = "success"
|
||||
integration.last_error = None
|
||||
message = sync_result.get("message", "Sync completed successfully.")
|
||||
if sync_result.get("synced_count"):
|
||||
message += f" Synced {sync_result['synced_count']} items."
|
||||
n = sync_result_item_count(sync_result)
|
||||
if n:
|
||||
message += f" Synced {n} items."
|
||||
flash(_("Sync completed successfully. %(details)s", details=message), "success")
|
||||
else:
|
||||
integration.last_sync_status = "error"
|
||||
@@ -898,16 +928,13 @@ def sync_integration(integration_id):
|
||||
flash(_("Sync failed: %(message)s", message=sync_result.get("message", "Unknown error")), "error")
|
||||
|
||||
# Log sync event
|
||||
_n = sync_result_item_count(sync_result)
|
||||
service._log_event(
|
||||
integration_id,
|
||||
"sync",
|
||||
sync_result.get("success", False),
|
||||
sync_result.get("message"),
|
||||
(
|
||||
{"synced_count": sync_result.get("synced_count")}
|
||||
if sync_result.get("success") and sync_result.get("synced_count")
|
||||
else None
|
||||
),
|
||||
({"synced_count": _n, "synced_items": _n} if sync_result.get("success") and _n else None),
|
||||
)
|
||||
|
||||
if not safe_commit("update_integration_sync_status", {"integration_id": integration_id}):
|
||||
|
||||
+37
-48
@@ -1162,36 +1162,30 @@ def export_invoice_pdf(invoice_id):
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
trace.get_current_span().set_attribute("pdf_size_bytes", len(pdf_bytes))
|
||||
record_invoice_duration_seconds(time.monotonic() - _pdf_t0, "pdf")
|
||||
# Optionally embed Factur-X CII XML in PDF (strict: fail export if embed fails)
|
||||
if getattr(settings, "invoices_zugferd_pdf", False):
|
||||
from app.utils.zugferd import embed_zugferd_xml_in_pdf
|
||||
from app.utils.invoice_pdf_postprocess import postprocess_invoice_pdf_bytes
|
||||
|
||||
pdf_bytes, embed_err = embed_zugferd_xml_in_pdf(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
current_app.logger.warning(
|
||||
f"[PDF_EXPORT] Factur-X embed failed - InvoiceID: {invoice_id}, Error: {embed_err}"
|
||||
)
|
||||
flash(
|
||||
_(
|
||||
"Factur-X embedding is enabled but failed: %(err)s. Export aborted so the PDF does not ship without embedded XML.",
|
||||
err=embed_err,
|
||||
),
|
||||
"error",
|
||||
)
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
if getattr(settings, "invoices_pdfa3_compliant", False):
|
||||
from app.utils.pdfa3 import convert_to_pdfa3
|
||||
|
||||
pdf_bytes, pdfa_err = convert_to_pdfa3(pdf_bytes)
|
||||
if pdfa_err:
|
||||
current_app.logger.warning(
|
||||
f"[PDF_EXPORT] PDF/A-3 conversion failed - InvoiceID: {invoice_id}, Error: {pdfa_err}"
|
||||
)
|
||||
flash(
|
||||
_("PDF/A-3 normalization failed: %(err)s. Export aborted.", err=pdfa_err),
|
||||
"error",
|
||||
)
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
pdf_bytes, embed_err, pdfa_err = postprocess_invoice_pdf_bytes(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
current_app.logger.warning(
|
||||
f"[PDF_EXPORT] Factur-X embed failed - InvoiceID: {invoice_id}, Error: {embed_err}"
|
||||
)
|
||||
flash(
|
||||
_(
|
||||
"Factur-X embedding is enabled but failed: %(err)s. Export aborted so the PDF does not ship without embedded XML.",
|
||||
err=embed_err,
|
||||
),
|
||||
"error",
|
||||
)
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
if pdfa_err:
|
||||
current_app.logger.warning(
|
||||
f"[PDF_EXPORT] PDF/A-3 conversion failed - InvoiceID: {invoice_id}, Error: {pdfa_err}"
|
||||
)
|
||||
flash(
|
||||
_("PDF/A-3 normalization failed: %(err)s. Export aborted.", err=pdfa_err),
|
||||
"error",
|
||||
)
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
# Optional: run veraPDF and surface summary (does not block)
|
||||
if getattr(settings, "invoices_validate_export", False):
|
||||
@@ -1237,26 +1231,21 @@ def export_invoice_pdf(invoice_id):
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
trace.get_current_span().set_attribute("pdf_size_bytes", len(pdf_bytes))
|
||||
record_invoice_duration_seconds(time.monotonic() - _pdf_t0, "pdf")
|
||||
if getattr(settings, "invoices_zugferd_pdf", False):
|
||||
from app.utils.zugferd import embed_zugferd_xml_in_pdf
|
||||
from app.utils.invoice_pdf_postprocess import postprocess_invoice_pdf_bytes
|
||||
|
||||
pdf_bytes, embed_err = embed_zugferd_xml_in_pdf(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
current_app.logger.warning(
|
||||
f"[PDF_EXPORT] Factur-X embed failed (fallback path) - InvoiceID: {invoice_id}, Error: {embed_err}"
|
||||
)
|
||||
flash(
|
||||
_("Factur-X embedding is enabled but failed: %(err)s. Export aborted.", err=embed_err),
|
||||
"error",
|
||||
)
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
if getattr(settings, "invoices_pdfa3_compliant", False):
|
||||
from app.utils.pdfa3 import convert_to_pdfa3
|
||||
|
||||
pdf_bytes, pdfa_err = convert_to_pdfa3(pdf_bytes)
|
||||
if pdfa_err:
|
||||
flash(_("PDF/A-3 normalization failed: %(err)s. Export aborted.", err=pdfa_err), "error")
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
pdf_bytes, embed_err, pdfa_err = postprocess_invoice_pdf_bytes(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
current_app.logger.warning(
|
||||
f"[PDF_EXPORT] Factur-X embed failed (fallback path) - InvoiceID: {invoice_id}, Error: {embed_err}"
|
||||
)
|
||||
flash(
|
||||
_("Factur-X embedding is enabled but failed: %(err)s. Export aborted.", err=embed_err),
|
||||
"error",
|
||||
)
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
if pdfa_err:
|
||||
flash(_("PDF/A-3 normalization failed: %(err)s. Export aborted.", err=pdfa_err), "error")
|
||||
return redirect(request.referrer or url_for("invoices.view_invoice", invoice_id=invoice.id))
|
||||
pdf_size_bytes = len(pdf_bytes)
|
||||
current_app.logger.info(
|
||||
f"[PDF_EXPORT] Fallback PDF generated successfully - PageSize: '{page_size}', InvoiceID: {invoice_id}, PDFSize: {pdf_size_bytes} bytes"
|
||||
|
||||
+477
-127
@@ -14,6 +14,50 @@ from app.utils.permissions import admin_or_permission_required, permission_requi
|
||||
quotes_bp = Blueprint("quotes", __name__)
|
||||
|
||||
|
||||
def _parse_quote_form_date(value):
|
||||
if not value or not str(value).strip():
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(str(value).strip()[:10], "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _pad_form_list(values, length):
|
||||
out = list(values)
|
||||
while len(out) < length:
|
||||
out.append("")
|
||||
return out
|
||||
|
||||
|
||||
def _quote_form_inventory_context():
|
||||
"""Stock + warehouse lists and JSON for quote create/edit forms."""
|
||||
import json
|
||||
|
||||
from app.models import StockItem, Warehouse
|
||||
|
||||
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
|
||||
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
|
||||
return {
|
||||
"stock_items": stock_items,
|
||||
"warehouses": warehouses,
|
||||
"stock_items_json": json.dumps(
|
||||
[
|
||||
{
|
||||
"id": item.id,
|
||||
"sku": item.sku,
|
||||
"name": item.name,
|
||||
"default_price": float(item.default_price) if item.default_price else None,
|
||||
"unit": item.unit or "pcs",
|
||||
"description": item.name,
|
||||
}
|
||||
for item in stock_items
|
||||
]
|
||||
),
|
||||
"warehouses_json": json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses]),
|
||||
}
|
||||
|
||||
|
||||
@quotes_bp.route("/quotes")
|
||||
@login_required
|
||||
def list_quotes():
|
||||
@@ -94,6 +138,7 @@ def create_quote():
|
||||
discount_amount = request.form.get("discount_amount", "").strip()
|
||||
discount_reason = request.form.get("discount_reason", "").strip()
|
||||
coupon_code = request.form.get("coupon_code", "").strip()
|
||||
requires_approval = request.form.get("requires_approval") == "true"
|
||||
|
||||
try:
|
||||
current_app.logger.info(
|
||||
@@ -109,7 +154,11 @@ def create_quote():
|
||||
if not title or not client_id:
|
||||
flash(_("Quote title and client are required"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
# Get client and validate
|
||||
@@ -117,7 +166,11 @@ def create_quote():
|
||||
if not client:
|
||||
flash(_("Selected client not found"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
# Validate amounts
|
||||
@@ -128,7 +181,11 @@ def create_quote():
|
||||
except (InvalidOperation, ValueError):
|
||||
flash(_("Invalid total amount format"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -138,7 +195,11 @@ def create_quote():
|
||||
except (InvalidOperation, ValueError):
|
||||
flash(_("Invalid hourly rate format"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -148,7 +209,11 @@ def create_quote():
|
||||
except ValueError:
|
||||
flash(_("Invalid estimated hours format"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -158,7 +223,11 @@ def create_quote():
|
||||
except (InvalidOperation, ValueError):
|
||||
flash(_("Invalid tax rate format"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
# Validate discount fields
|
||||
@@ -177,7 +246,11 @@ def create_quote():
|
||||
except (InvalidOperation, ValueError):
|
||||
flash(_("Invalid discount amount format"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
# Parse valid_until date
|
||||
@@ -188,7 +261,11 @@ def create_quote():
|
||||
except ValueError:
|
||||
flash(_("Invalid date format for valid until"), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
# Generate quote number
|
||||
@@ -211,46 +288,171 @@ def create_quote():
|
||||
discount_amount=discount_amount_decimal if discount_amount_decimal else None,
|
||||
discount_reason=discount_reason if discount_reason else None,
|
||||
coupon_code=coupon_code.upper() if coupon_code else None,
|
||||
requires_approval=requires_approval,
|
||||
)
|
||||
|
||||
db.session.add(quote)
|
||||
db.session.flush() # Get quote ID for items
|
||||
|
||||
# Process line items if provided
|
||||
# Process line items (items + expenses + goods — issue #585)
|
||||
item_descriptions = request.form.getlist("item_description[]")
|
||||
item_quantities = request.form.getlist("item_quantity[]")
|
||||
item_prices = request.form.getlist("item_price[]")
|
||||
item_units = request.form.getlist("item_unit[]")
|
||||
item_line_sources = request.form.getlist("item_line_source[]")
|
||||
item_stock_ids = request.form.getlist("item_stock_item_id[]")
|
||||
item_warehouse_ids = request.form.getlist("item_warehouse_id[]")
|
||||
|
||||
for desc, qty, price, unit, stock_id, wh_id in zip(
|
||||
item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids
|
||||
):
|
||||
if desc.strip():
|
||||
try:
|
||||
stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None
|
||||
warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None
|
||||
qe_titles = request.form.getlist("qe_title[]")
|
||||
qe_descriptions = request.form.getlist("qe_description[]")
|
||||
qe_categories = request.form.getlist("qe_category[]")
|
||||
qe_amounts = request.form.getlist("qe_amount[]")
|
||||
qe_dates = request.form.getlist("qe_date[]")
|
||||
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=desc.strip(),
|
||||
quantity=Decimal(qty) if qty else Decimal("1"),
|
||||
unit_price=Decimal(price) if price else Decimal("0"),
|
||||
unit=unit.strip() if unit else None,
|
||||
stock_item_id=stock_item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
)
|
||||
db.session.add(item)
|
||||
except (ValueError, InvalidOperation):
|
||||
pass # Skip invalid items
|
||||
qg_names = request.form.getlist("qg_name[]")
|
||||
qg_descriptions = request.form.getlist("qg_description[]")
|
||||
qg_categories = request.form.getlist("qg_category[]")
|
||||
qg_quantities = request.form.getlist("qg_quantity[]")
|
||||
qg_prices = request.form.getlist("qg_unit_price[]")
|
||||
qg_skus = request.form.getlist("qg_sku[]")
|
||||
|
||||
n_items = len(item_descriptions)
|
||||
item_line_sources = _pad_form_list(item_line_sources, n_items)
|
||||
item_quantities = _pad_form_list(item_quantities, n_items)
|
||||
item_prices = _pad_form_list(item_prices, n_items)
|
||||
item_units = _pad_form_list(item_units, n_items)
|
||||
item_stock_ids = _pad_form_list(item_stock_ids, n_items)
|
||||
item_warehouse_ids = _pad_form_list(item_warehouse_ids, n_items)
|
||||
|
||||
n_qe = len(qe_titles)
|
||||
qe_descriptions = _pad_form_list(qe_descriptions, n_qe)
|
||||
qe_categories = _pad_form_list(qe_categories, n_qe)
|
||||
qe_amounts = _pad_form_list(qe_amounts, n_qe)
|
||||
qe_dates = _pad_form_list(qe_dates, n_qe)
|
||||
|
||||
n_qg = len(qg_names)
|
||||
qg_descriptions = _pad_form_list(qg_descriptions, n_qg)
|
||||
qg_categories = _pad_form_list(qg_categories, n_qg)
|
||||
qg_quantities = _pad_form_list(qg_quantities, n_qg)
|
||||
qg_prices = _pad_form_list(qg_prices, n_qg)
|
||||
qg_skus = _pad_form_list(qg_skus, n_qg)
|
||||
|
||||
line_position = 0
|
||||
|
||||
for desc, qty, price, unit, src, stock_id, wh_id in zip(
|
||||
item_descriptions,
|
||||
item_quantities,
|
||||
item_prices,
|
||||
item_units,
|
||||
item_line_sources,
|
||||
item_stock_ids,
|
||||
item_warehouse_ids,
|
||||
):
|
||||
use_stock = (src or "").strip().lower() == "stock"
|
||||
try:
|
||||
stock_item_id = int(stock_id) if stock_id and str(stock_id).strip() and use_stock else None
|
||||
warehouse_id = int(wh_id) if wh_id and str(wh_id).strip() and use_stock else None
|
||||
except (TypeError, ValueError):
|
||||
stock_item_id, warehouse_id = None, None
|
||||
if not use_stock:
|
||||
stock_item_id, warehouse_id = None, None
|
||||
desc_s = (desc or "").strip()
|
||||
if not desc_s and not stock_item_id:
|
||||
continue
|
||||
try:
|
||||
q_dec = Decimal(qty) if qty and str(qty).strip() else Decimal("1")
|
||||
p_dec = Decimal(price) if price and str(price).strip() else Decimal("0")
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=desc_s or "-",
|
||||
quantity=q_dec,
|
||||
unit_price=p_dec,
|
||||
unit=unit.strip() if unit and str(unit).strip() else None,
|
||||
stock_item_id=stock_item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
position=line_position,
|
||||
line_kind="item",
|
||||
)
|
||||
db.session.add(item)
|
||||
line_position += 1
|
||||
except (ValueError, InvalidOperation):
|
||||
pass
|
||||
|
||||
for title, qe_desc, cat, amount, qe_d in zip(
|
||||
qe_titles, qe_descriptions, qe_categories, qe_amounts, qe_dates
|
||||
):
|
||||
title_s = (title or "").strip()
|
||||
qe_desc_s = (qe_desc or "").strip()
|
||||
if not title_s and not qe_desc_s and not (amount and str(amount).strip()):
|
||||
continue
|
||||
try:
|
||||
amt = Decimal(amount) if amount and str(amount).strip() else Decimal("0")
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
if amt <= 0 and not title_s and not qe_desc_s:
|
||||
continue
|
||||
ld = _parse_quote_form_date(qe_d)
|
||||
cat_s = (cat or "").strip() or None
|
||||
try:
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=qe_desc_s if qe_desc_s else (title_s or "-"),
|
||||
quantity=Decimal("1"),
|
||||
unit_price=amt,
|
||||
line_kind="expense",
|
||||
display_name=title_s or None,
|
||||
category=cat_s,
|
||||
line_date=ld,
|
||||
position=line_position,
|
||||
)
|
||||
db.session.add(item)
|
||||
line_position += 1
|
||||
except (InvalidOperation, ValueError):
|
||||
pass
|
||||
|
||||
for name, g_desc, g_cat, g_qty, g_price, g_sku in zip(
|
||||
qg_names, qg_descriptions, qg_categories, qg_quantities, qg_prices, qg_skus
|
||||
):
|
||||
name_s = (name or "").strip()
|
||||
g_desc_s = (g_desc or "").strip()
|
||||
if not name_s and not g_desc_s:
|
||||
continue
|
||||
try:
|
||||
gq = Decimal(g_qty) if g_qty and str(g_qty).strip() else Decimal("1")
|
||||
gp = Decimal(g_price) if g_price and str(g_price).strip() else Decimal("0")
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
if gq <= 0 or gp < 0:
|
||||
continue
|
||||
g_cat_s = (g_cat or "").strip() or None
|
||||
g_sku_s = (g_sku or "").strip() or None
|
||||
try:
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=g_desc_s if g_desc_s else (name_s or "-"),
|
||||
quantity=gq,
|
||||
unit_price=gp,
|
||||
line_kind="good",
|
||||
display_name=name_s or None,
|
||||
category=g_cat_s,
|
||||
sku=g_sku_s,
|
||||
position=line_position,
|
||||
)
|
||||
db.session.add(item)
|
||||
line_position += 1
|
||||
except (InvalidOperation, ValueError):
|
||||
pass
|
||||
|
||||
quote.calculate_totals()
|
||||
|
||||
if not safe_commit("create_quote", {"title": title, "client_id": client_id}):
|
||||
flash(_("Could not create quote due to a database error. Please check server logs."), "error")
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
# Log event
|
||||
@@ -263,7 +465,11 @@ def create_quote():
|
||||
return redirect(url_for("quotes.view_quote", quote_id=quote.id))
|
||||
|
||||
return render_template(
|
||||
"quotes/create.html", clients=clients, only_one_client=only_one_client, single_client=single_client
|
||||
"quotes/create.html",
|
||||
clients=clients,
|
||||
only_one_client=only_one_client,
|
||||
single_client=single_client,
|
||||
**_quote_form_inventory_context(),
|
||||
)
|
||||
|
||||
|
||||
@@ -335,7 +541,8 @@ def edit_quote(quote_id):
|
||||
raise InvalidOperation
|
||||
except (InvalidOperation, ValueError):
|
||||
flash(_("Invalid tax rate format"), "error")
|
||||
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
|
||||
inv = _quote_form_inventory_context()
|
||||
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients(), **inv)
|
||||
|
||||
# Validate discount fields
|
||||
discount_amount_decimal = None
|
||||
@@ -352,7 +559,8 @@ def edit_quote(quote_id):
|
||||
discount_type = None # Invalid type, ignore discount
|
||||
except (InvalidOperation, ValueError):
|
||||
flash(_("Invalid discount amount format"), "error")
|
||||
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
|
||||
inv = _quote_form_inventory_context()
|
||||
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients(), **inv)
|
||||
|
||||
# Parse valid_until date
|
||||
valid_until_date = None
|
||||
@@ -361,7 +569,8 @@ def edit_quote(quote_id):
|
||||
valid_until_date = datetime.strptime(valid_until, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
flash(_("Invalid date format for valid until"), "error")
|
||||
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients())
|
||||
inv = _quote_form_inventory_context()
|
||||
return render_template("quotes/edit.html", quote=quote, clients=Client.get_active_clients(), **inv)
|
||||
|
||||
# Update quote
|
||||
quote.title = title
|
||||
@@ -390,96 +599,243 @@ def edit_quote(quote_id):
|
||||
quote.discount_reason = discount_reason.strip() if discount_reason else None
|
||||
quote.coupon_code = coupon_code.upper().strip() if coupon_code else None
|
||||
|
||||
# Update line items
|
||||
# Update line items (items + expenses + goods — issue #585)
|
||||
item_ids = request.form.getlist("item_id[]")
|
||||
item_descriptions = request.form.getlist("item_description[]")
|
||||
item_quantities = request.form.getlist("item_quantity[]")
|
||||
item_prices = request.form.getlist("item_price[]")
|
||||
item_units = request.form.getlist("item_unit[]")
|
||||
|
||||
# Delete items not in the form
|
||||
existing_item_ids = {int(id) for id in item_ids if id}
|
||||
for item in quote.items:
|
||||
if item.id not in existing_item_ids:
|
||||
db.session.delete(item)
|
||||
|
||||
# Update or create items
|
||||
item_line_sources = request.form.getlist("item_line_source[]")
|
||||
item_stock_ids = request.form.getlist("item_stock_item_id[]")
|
||||
item_warehouse_ids = request.form.getlist("item_warehouse_id[]")
|
||||
|
||||
# Pad lists to match length
|
||||
while len(item_stock_ids) < len(item_ids):
|
||||
item_stock_ids.append("")
|
||||
while len(item_warehouse_ids) < len(item_ids):
|
||||
item_warehouse_ids.append("")
|
||||
qe_ids = request.form.getlist("qe_id[]")
|
||||
qe_titles = request.form.getlist("qe_title[]")
|
||||
qe_descriptions = request.form.getlist("qe_description[]")
|
||||
qe_categories = request.form.getlist("qe_category[]")
|
||||
qe_amounts = request.form.getlist("qe_amount[]")
|
||||
qe_dates = request.form.getlist("qe_date[]")
|
||||
|
||||
for item_id, desc, qty, price, unit, stock_id, wh_id in zip(
|
||||
item_ids, item_descriptions, item_quantities, item_prices, item_units, item_stock_ids, item_warehouse_ids
|
||||
):
|
||||
if desc.strip():
|
||||
qg_ids = request.form.getlist("qg_id[]")
|
||||
qg_names = request.form.getlist("qg_name[]")
|
||||
qg_descriptions = request.form.getlist("qg_description[]")
|
||||
qg_categories = request.form.getlist("qg_category[]")
|
||||
qg_quantities = request.form.getlist("qg_quantity[]")
|
||||
qg_prices = request.form.getlist("qg_unit_price[]")
|
||||
qg_skus = request.form.getlist("qg_sku[]")
|
||||
|
||||
n_items = len(item_descriptions)
|
||||
item_ids = _pad_form_list(item_ids, n_items)
|
||||
item_line_sources = _pad_form_list(item_line_sources, n_items)
|
||||
item_quantities = _pad_form_list(item_quantities, n_items)
|
||||
item_prices = _pad_form_list(item_prices, n_items)
|
||||
item_units = _pad_form_list(item_units, n_items)
|
||||
item_stock_ids = _pad_form_list(item_stock_ids, n_items)
|
||||
item_warehouse_ids = _pad_form_list(item_warehouse_ids, n_items)
|
||||
|
||||
n_qe = len(qe_titles)
|
||||
qe_ids = _pad_form_list(qe_ids, n_qe)
|
||||
qe_descriptions = _pad_form_list(qe_descriptions, n_qe)
|
||||
qe_categories = _pad_form_list(qe_categories, n_qe)
|
||||
qe_amounts = _pad_form_list(qe_amounts, n_qe)
|
||||
qe_dates = _pad_form_list(qe_dates, n_qe)
|
||||
|
||||
n_qg = len(qg_names)
|
||||
qg_ids = _pad_form_list(qg_ids, n_qg)
|
||||
qg_descriptions = _pad_form_list(qg_descriptions, n_qg)
|
||||
qg_categories = _pad_form_list(qg_categories, n_qg)
|
||||
qg_quantities = _pad_form_list(qg_quantities, n_qg)
|
||||
qg_prices = _pad_form_list(qg_prices, n_qg)
|
||||
qg_skus = _pad_form_list(qg_skus, n_qg)
|
||||
|
||||
existing_item_ids = set()
|
||||
for raw in item_ids + qe_ids + qg_ids:
|
||||
if raw and str(raw).strip():
|
||||
try:
|
||||
stock_item_id = int(stock_id) if stock_id and stock_id.strip() else None
|
||||
warehouse_id = int(wh_id) if wh_id and wh_id.strip() else None
|
||||
existing_item_ids.add(int(raw))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
for row in list(quote.items):
|
||||
if row.id not in existing_item_ids:
|
||||
db.session.delete(row)
|
||||
|
||||
if item_id:
|
||||
# Update existing item
|
||||
item = QuoteItem.query.get(item_id)
|
||||
if item and item.quote_id == quote.id:
|
||||
item.description = desc.strip()
|
||||
item.quantity = Decimal(qty) if qty else Decimal("1")
|
||||
item.unit_price = Decimal(price) if price else Decimal("0")
|
||||
item.total_amount = item.quantity * item.unit_price
|
||||
item.unit = unit.strip() if unit else None
|
||||
item.stock_item_id = stock_item_id
|
||||
item.warehouse_id = warehouse_id
|
||||
item.is_stock_item = stock_item_id is not None
|
||||
else:
|
||||
# Create new item
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=desc.strip(),
|
||||
quantity=Decimal(qty) if qty else Decimal("1"),
|
||||
unit_price=Decimal(price) if price else Decimal("0"),
|
||||
unit=unit.strip() if unit else None,
|
||||
stock_item_id=stock_item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
)
|
||||
db.session.add(item)
|
||||
except (ValueError, InvalidOperation):
|
||||
pass # Skip invalid items
|
||||
line_position = 0
|
||||
|
||||
for item_id, desc, qty, price, unit, src, stock_id, wh_id in zip(
|
||||
item_ids,
|
||||
item_descriptions,
|
||||
item_quantities,
|
||||
item_prices,
|
||||
item_units,
|
||||
item_line_sources,
|
||||
item_stock_ids,
|
||||
item_warehouse_ids,
|
||||
):
|
||||
use_stock = (src or "").strip().lower() == "stock"
|
||||
try:
|
||||
stock_item_id = int(stock_id) if stock_id and str(stock_id).strip() and use_stock else None
|
||||
warehouse_id = int(wh_id) if wh_id and str(wh_id).strip() and use_stock else None
|
||||
except (TypeError, ValueError):
|
||||
stock_item_id, warehouse_id = None, None
|
||||
if not use_stock:
|
||||
stock_item_id, warehouse_id = None, None
|
||||
desc_s = (desc or "").strip()
|
||||
if not desc_s and not stock_item_id:
|
||||
continue
|
||||
try:
|
||||
q_dec = Decimal(qty) if qty and str(qty).strip() else Decimal("1")
|
||||
p_dec = Decimal(price) if price and str(price).strip() else Decimal("0")
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
try:
|
||||
if item_id and str(item_id).strip():
|
||||
item = QuoteItem.query.get(int(item_id))
|
||||
if not item or item.quote_id != quote.id:
|
||||
continue
|
||||
item.line_kind = "item"
|
||||
item.display_name = None
|
||||
item.category = None
|
||||
item.line_date = None
|
||||
item.sku = None
|
||||
item.description = desc_s or "-"
|
||||
item.quantity = q_dec
|
||||
item.unit_price = p_dec
|
||||
item.total_amount = q_dec * p_dec
|
||||
item.unit = unit.strip() if unit and str(unit).strip() else None
|
||||
item.stock_item_id = stock_item_id
|
||||
item.warehouse_id = warehouse_id
|
||||
item.is_stock_item = stock_item_id is not None
|
||||
item.position = line_position
|
||||
else:
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=desc_s or "-",
|
||||
quantity=q_dec,
|
||||
unit_price=p_dec,
|
||||
unit=unit.strip() if unit and str(unit).strip() else None,
|
||||
stock_item_id=stock_item_id,
|
||||
warehouse_id=warehouse_id,
|
||||
position=line_position,
|
||||
line_kind="item",
|
||||
)
|
||||
db.session.add(item)
|
||||
line_position += 1
|
||||
except (TypeError, ValueError, InvalidOperation):
|
||||
pass
|
||||
|
||||
for qe_id, title, qe_desc, cat, amount, qe_d in zip(
|
||||
qe_ids, qe_titles, qe_descriptions, qe_categories, qe_amounts, qe_dates
|
||||
):
|
||||
title_s = (title or "").strip()
|
||||
qe_desc_s = (qe_desc or "").strip()
|
||||
if not title_s and not qe_desc_s and not (amount and str(amount).strip()):
|
||||
continue
|
||||
try:
|
||||
amt = Decimal(amount) if amount and str(amount).strip() else Decimal("0")
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
if amt <= 0 and not title_s and not qe_desc_s:
|
||||
continue
|
||||
ld = _parse_quote_form_date(qe_d)
|
||||
cat_s = (cat or "").strip() or None
|
||||
try:
|
||||
if qe_id and str(qe_id).strip():
|
||||
item = QuoteItem.query.get(int(qe_id))
|
||||
if not item or item.quote_id != quote.id:
|
||||
continue
|
||||
item.line_kind = "expense"
|
||||
item.display_name = title_s or None
|
||||
item.description = qe_desc_s if qe_desc_s else (title_s or "-")
|
||||
item.category = cat_s
|
||||
item.line_date = ld
|
||||
item.sku = None
|
||||
item.quantity = Decimal("1")
|
||||
item.unit_price = amt
|
||||
item.total_amount = amt
|
||||
item.unit = None
|
||||
item.stock_item_id = None
|
||||
item.warehouse_id = None
|
||||
item.is_stock_item = False
|
||||
item.position = line_position
|
||||
else:
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=qe_desc_s if qe_desc_s else (title_s or "-"),
|
||||
quantity=Decimal("1"),
|
||||
unit_price=amt,
|
||||
line_kind="expense",
|
||||
display_name=title_s or None,
|
||||
category=cat_s,
|
||||
line_date=ld,
|
||||
position=line_position,
|
||||
)
|
||||
db.session.add(item)
|
||||
line_position += 1
|
||||
except (TypeError, ValueError, InvalidOperation):
|
||||
pass
|
||||
|
||||
for qg_id, name, g_desc, g_cat, g_qty, g_price, g_sku in zip(
|
||||
qg_ids, qg_names, qg_descriptions, qg_categories, qg_quantities, qg_prices, qg_skus
|
||||
):
|
||||
name_s = (name or "").strip()
|
||||
g_desc_s = (g_desc or "").strip()
|
||||
if not name_s and not g_desc_s:
|
||||
continue
|
||||
try:
|
||||
gq = Decimal(g_qty) if g_qty and str(g_qty).strip() else Decimal("1")
|
||||
gp = Decimal(g_price) if g_price and str(g_price).strip() else Decimal("0")
|
||||
except (InvalidOperation, ValueError):
|
||||
continue
|
||||
if gq <= 0 or gp < 0:
|
||||
continue
|
||||
g_cat_s = (g_cat or "").strip() or None
|
||||
g_sku_s = (g_sku or "").strip() or None
|
||||
try:
|
||||
if qg_id and str(qg_id).strip():
|
||||
item = QuoteItem.query.get(int(qg_id))
|
||||
if not item or item.quote_id != quote.id:
|
||||
continue
|
||||
item.line_kind = "good"
|
||||
item.display_name = name_s or None
|
||||
item.description = g_desc_s if g_desc_s else (name_s or "-")
|
||||
item.category = g_cat_s
|
||||
item.line_date = None
|
||||
item.sku = g_sku_s
|
||||
item.quantity = gq
|
||||
item.unit_price = gp
|
||||
item.total_amount = gq * gp
|
||||
item.unit = None
|
||||
item.stock_item_id = None
|
||||
item.warehouse_id = None
|
||||
item.is_stock_item = False
|
||||
item.position = line_position
|
||||
else:
|
||||
item = QuoteItem(
|
||||
quote_id=quote.id,
|
||||
description=g_desc_s if g_desc_s else (name_s or "-"),
|
||||
quantity=gq,
|
||||
unit_price=gp,
|
||||
line_kind="good",
|
||||
display_name=name_s or None,
|
||||
category=g_cat_s,
|
||||
sku=g_sku_s,
|
||||
position=line_position,
|
||||
)
|
||||
db.session.add(item)
|
||||
line_position += 1
|
||||
except (TypeError, ValueError, InvalidOperation):
|
||||
pass
|
||||
|
||||
quote.calculate_totals()
|
||||
|
||||
if not safe_commit("edit_quote", {"quote_id": quote_id}):
|
||||
flash(_("Could not update quote due to a database error. Please check server logs."), "error")
|
||||
import json
|
||||
|
||||
from app.models import StockItem, Warehouse
|
||||
|
||||
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
|
||||
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
|
||||
stock_items_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": item.id,
|
||||
"sku": item.sku,
|
||||
"name": item.name,
|
||||
"default_price": float(item.default_price) if item.default_price else None,
|
||||
"unit": item.unit or "pcs",
|
||||
"description": item.name,
|
||||
}
|
||||
for item in stock_items
|
||||
]
|
||||
)
|
||||
warehouses_json = json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses])
|
||||
inv = _quote_form_inventory_context()
|
||||
return render_template(
|
||||
"quotes/edit.html",
|
||||
quote=quote,
|
||||
clients=Client.get_active_clients(),
|
||||
stock_items=stock_items,
|
||||
warehouses=warehouses,
|
||||
stock_items_json=stock_items_json,
|
||||
warehouses_json=warehouses_json,
|
||||
**inv,
|
||||
)
|
||||
|
||||
log_event("quote.updated", user_id=current_user.id, quote_id=quote.id, quote_title=title)
|
||||
@@ -488,34 +844,12 @@ def edit_quote(quote_id):
|
||||
flash(_("Quote updated successfully"), "success")
|
||||
return redirect(url_for("quotes.view_quote", quote_id=quote_id))
|
||||
|
||||
import json
|
||||
|
||||
from app.models import StockItem, Warehouse
|
||||
|
||||
stock_items = StockItem.query.filter_by(is_active=True).order_by(StockItem.name).all()
|
||||
warehouses = Warehouse.query.filter_by(is_active=True).order_by(Warehouse.code).all()
|
||||
stock_items_json = json.dumps(
|
||||
[
|
||||
{
|
||||
"id": item.id,
|
||||
"sku": item.sku,
|
||||
"name": item.name,
|
||||
"default_price": float(item.default_price) if item.default_price else None,
|
||||
"unit": item.unit or "pcs",
|
||||
"description": item.name,
|
||||
}
|
||||
for item in stock_items
|
||||
]
|
||||
)
|
||||
warehouses_json = json.dumps([{"id": wh.id, "code": wh.code, "name": wh.name} for wh in warehouses])
|
||||
inv = _quote_form_inventory_context()
|
||||
return render_template(
|
||||
"quotes/edit.html",
|
||||
quote=quote,
|
||||
clients=Client.get_active_clients(),
|
||||
stock_items=stock_items,
|
||||
warehouses=warehouses,
|
||||
stock_items_json=stock_items_json,
|
||||
warehouses_json=warehouses_json,
|
||||
**inv,
|
||||
)
|
||||
|
||||
|
||||
@@ -1444,6 +1778,14 @@ def duplicate_quote(quote_id):
|
||||
quantity=original_item.quantity,
|
||||
unit_price=original_item.unit_price,
|
||||
unit=original_item.unit,
|
||||
position=original_item.position,
|
||||
stock_item_id=original_item.stock_item_id,
|
||||
warehouse_id=original_item.warehouse_id,
|
||||
line_kind=getattr(original_item, "line_kind", None) or "item",
|
||||
display_name=getattr(original_item, "display_name", None),
|
||||
category=getattr(original_item, "category", None),
|
||||
line_date=getattr(original_item, "line_date", None),
|
||||
sku=getattr(original_item, "sku", None),
|
||||
)
|
||||
db.session.add(new_item)
|
||||
|
||||
@@ -1541,6 +1883,14 @@ def bulk_action():
|
||||
quantity=item.quantity,
|
||||
unit_price=item.unit_price,
|
||||
unit=item.unit,
|
||||
position=item.position,
|
||||
stock_item_id=item.stock_item_id,
|
||||
warehouse_id=item.warehouse_id,
|
||||
line_kind=getattr(item, "line_kind", None) or "item",
|
||||
display_name=getattr(item, "display_name", None),
|
||||
category=getattr(item, "category", None),
|
||||
line_date=getattr(item, "line_date", None),
|
||||
sku=getattr(item, "sku", None),
|
||||
)
|
||||
db.session.add(new_item)
|
||||
|
||||
|
||||
@@ -92,6 +92,10 @@ class TimeEntryCreateSchema(Schema):
|
||||
class TimeEntryUpdateSchema(Schema):
|
||||
"""Schema for updating a time entry"""
|
||||
|
||||
if_updated_at = fields.DateTime(
|
||||
allow_none=True,
|
||||
metadata={"description": "Last known updated_at for optimistic locking (ISO 8601)."},
|
||||
)
|
||||
project_id = fields.Int(allow_none=True)
|
||||
client_id = fields.Int(allow_none=True)
|
||||
task_id = fields.Int(allow_none=True)
|
||||
|
||||
@@ -284,22 +284,33 @@ class ApiTokenService:
|
||||
|
||||
def check_token_rate_limit(self, token_id: int, max_requests_per_hour: int = 1000) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if token has exceeded rate limit.
|
||||
This is a simple implementation - for production, use Redis or similar.
|
||||
Check if token has exceeded rate limit (delegates to api_rate_limit; increments counters).
|
||||
|
||||
Note: Prefer enforcing limits in ``require_api_token`` so each HTTP request is counted once.
|
||||
This method is kept for diagnostics and tests.
|
||||
|
||||
Args:
|
||||
token_id: The token ID
|
||||
max_requests_per_hour: Maximum requests per hour
|
||||
max_requests_per_hour: Ignored; limits come from Flask config
|
||||
|
||||
Returns:
|
||||
dict with 'allowed' bool and 'remaining' requests
|
||||
"""
|
||||
# This is a placeholder - in production, implement proper rate limiting
|
||||
# using Redis or similar distributed cache
|
||||
from flask import has_request_context
|
||||
|
||||
from app.utils.api_rate_limit import consume_api_token_rate_limit
|
||||
|
||||
api_token = ApiToken.query.get(token_id)
|
||||
if not api_token:
|
||||
return {"allowed": False, "remaining": 0, "error": "token_not_found"}
|
||||
|
||||
# Simple check: if usage_count is very high, might be rate limited
|
||||
# In production, track requests per hour in Redis
|
||||
return {"allowed": True, "remaining": max_requests_per_hour, "reset_at": datetime.utcnow() + timedelta(hours=1)}
|
||||
if not has_request_context():
|
||||
return {"allowed": True, "remaining": max_requests_per_hour, "reset_at": datetime.utcnow() + timedelta(hours=1)}
|
||||
|
||||
allowed, info = consume_api_token_rate_limit(token_id)
|
||||
return {
|
||||
"allowed": allowed,
|
||||
"remaining": info.get("remaining_minute", 0),
|
||||
"remaining_hour": info.get("remaining_hour", 0),
|
||||
"reset_at": datetime.utcnow() + timedelta(hours=1),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Bulk time entry actions shared by legacy session API and API v1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app import db
|
||||
from app.models import TimeEntry
|
||||
from app.models.time_entry import local_now
|
||||
from app.utils.db import safe_commit
|
||||
|
||||
|
||||
def apply_bulk_time_entry_actions(
|
||||
entry_ids: List[int],
|
||||
action: str,
|
||||
value: Any,
|
||||
*,
|
||||
user_id: int,
|
||||
is_admin: bool,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply bulk action to time entries. Same rules as legacy /api/entries/bulk.
|
||||
|
||||
Returns dict with keys: success (bool), affected (int), error (optional str),
|
||||
http_status (int).
|
||||
"""
|
||||
if not entry_ids:
|
||||
return {"success": False, "error": "entry_ids must be a non-empty list", "http_status": 400}
|
||||
if action not in {"delete", "set_billable", "set_paid", "add_tag", "remove_tag"}:
|
||||
return {"success": False, "error": "Unsupported action", "http_status": 400}
|
||||
|
||||
q = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids))
|
||||
entries = q.all()
|
||||
if not entries:
|
||||
return {"success": False, "error": "No entries found", "http_status": 404}
|
||||
|
||||
if not is_admin:
|
||||
for e in entries:
|
||||
if e.user_id != user_id:
|
||||
return {"success": False, "error": "Access denied for one or more entries", "http_status": 403}
|
||||
|
||||
affected = 0
|
||||
if action == "delete":
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
db.session.delete(e)
|
||||
affected += 1
|
||||
elif action == "set_billable":
|
||||
flag = bool(value)
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
e.billable = flag
|
||||
e.updated_at = local_now()
|
||||
affected += 1
|
||||
elif action == "set_paid":
|
||||
flag = bool(value)
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
e.set_paid(flag)
|
||||
affected += 1
|
||||
elif action in {"add_tag", "remove_tag"}:
|
||||
tag = (value or "").strip() if value is not None else ""
|
||||
if not tag:
|
||||
return {"success": False, "error": "Tag value is required", "http_status": 400}
|
||||
for e in entries:
|
||||
if e.is_active:
|
||||
continue
|
||||
tags = set(e.tag_list)
|
||||
if action == "add_tag":
|
||||
tags.add(tag)
|
||||
else:
|
||||
tags.discard(tag)
|
||||
e.tags = ", ".join(sorted(tags)) if tags else None
|
||||
e.updated_at = local_now()
|
||||
affected += 1
|
||||
|
||||
if affected > 0:
|
||||
if not safe_commit("bulk_time_entries", {"action": action, "count": affected}):
|
||||
return {"success": False, "error": "Database error during bulk operation", "http_status": 500}
|
||||
else:
|
||||
db.session.rollback()
|
||||
if entries:
|
||||
return {
|
||||
"success": False,
|
||||
"error": "No entries were updated; active (running) time entries cannot be changed with this bulk action",
|
||||
"http_status": 400,
|
||||
"affected": 0,
|
||||
}
|
||||
|
||||
return {"success": True, "affected": affected, "http_status": 200}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""CSV import for time entries (API v1)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from app.services import TimeTrackingService
|
||||
from app.utils.scope_filter import user_can_access_project
|
||||
|
||||
|
||||
def _parse_dt(val: str):
|
||||
if not val or not str(val).strip():
|
||||
return None
|
||||
s = str(val).strip()
|
||||
if s.endswith("Z"):
|
||||
s = s[:-1] + "+00:00"
|
||||
try:
|
||||
return datetime.fromisoformat(s)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_bool(val: Any) -> bool:
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if val is None:
|
||||
return True
|
||||
return str(val).strip().lower() in {"1", "true", "yes", "y"}
|
||||
|
||||
|
||||
def import_time_entries_from_csv_text(
|
||||
csv_text: str,
|
||||
*,
|
||||
user_id: int,
|
||||
is_admin: bool,
|
||||
) -> Tuple[Dict[str, Any], int]:
|
||||
"""
|
||||
Parse CSV and create time entries for the given user.
|
||||
|
||||
Required columns: start_time, end_time, project_id
|
||||
Optional: task_id, notes, tags, billable
|
||||
|
||||
Returns (result_dict, http_status).
|
||||
"""
|
||||
if not csv_text or not csv_text.strip():
|
||||
return {"success": False, "error": "Empty CSV"}, 400
|
||||
|
||||
f = io.StringIO(csv_text)
|
||||
reader = csv.DictReader(f)
|
||||
if not reader.fieldnames:
|
||||
return {"success": False, "error": "CSV must include a header row"}, 400
|
||||
|
||||
fields_lower = {h.lower().strip(): h for h in reader.fieldnames if h}
|
||||
|
||||
def col(name: str) -> str:
|
||||
for key, orig in fields_lower.items():
|
||||
if key.replace(" ", "_") == name.lower().replace(" ", "_"):
|
||||
return orig
|
||||
return name
|
||||
|
||||
svc = TimeTrackingService()
|
||||
created = 0
|
||||
failed: List[Dict[str, Any]] = []
|
||||
row_num = 1
|
||||
|
||||
for row in reader:
|
||||
row_num += 1
|
||||
try:
|
||||
pid_raw = row.get(col("project_id")) or row.get(col("project id"))
|
||||
if pid_raw is None or str(pid_raw).strip() == "":
|
||||
failed.append({"row": row_num, "error": "project_id is required"})
|
||||
continue
|
||||
project_id = int(str(pid_raw).strip())
|
||||
|
||||
if not user_can_access_project_by_id(user_id, project_id, is_admin):
|
||||
failed.append({"row": row_num, "error": "no access to project"})
|
||||
continue
|
||||
|
||||
st = _parse_dt(row.get(col("start_time")) or row.get(col("start")))
|
||||
et = _parse_dt(row.get(col("end_time")) or row.get(col("end")))
|
||||
if not st or not et:
|
||||
failed.append({"row": row_num, "error": "start_time and end_time required (ISO 8601)"})
|
||||
continue
|
||||
|
||||
task_id = None
|
||||
tr = row.get(col("task_id")) or row.get(col("task id"))
|
||||
if tr is not None and str(tr).strip() != "":
|
||||
task_id = int(str(tr).strip())
|
||||
|
||||
notes = (row.get(col("notes")) or row.get(col("description")) or "").strip() or None
|
||||
tags = (row.get(col("tags")) or "").strip() or None
|
||||
billable = _parse_bool(row.get(col("billable")))
|
||||
|
||||
res = svc.create_manual_entry(
|
||||
user_id=user_id,
|
||||
project_id=project_id,
|
||||
client_id=None,
|
||||
start_time=st,
|
||||
end_time=et,
|
||||
task_id=task_id,
|
||||
notes=notes,
|
||||
tags=tags,
|
||||
billable=billable,
|
||||
paid=False,
|
||||
skip_entry_requirements=is_admin,
|
||||
)
|
||||
if res.get("success"):
|
||||
created += 1
|
||||
else:
|
||||
failed.append({"row": row_num, "error": res.get("message", "create failed")})
|
||||
except Exception as e:
|
||||
failed.append({"row": row_num, "error": str(e)})
|
||||
|
||||
return (
|
||||
{
|
||||
"success": True,
|
||||
"created": created,
|
||||
"failed": len(failed),
|
||||
"errors": failed[:50],
|
||||
},
|
||||
200,
|
||||
)
|
||||
|
||||
|
||||
def user_can_access_project_by_id(user_id: int, project_id: int, is_admin: bool) -> bool:
|
||||
from app.models import User
|
||||
|
||||
u = User.query.get(user_id)
|
||||
if not u:
|
||||
return False
|
||||
return user_can_access_project(u, project_id)
|
||||
@@ -417,6 +417,7 @@ class TimeTrackingService:
|
||||
paid: Optional[bool] = None,
|
||||
invoice_number: Optional[str] = None,
|
||||
reason: Optional[str] = None,
|
||||
expected_updated_at: Optional[datetime] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update a time entry.
|
||||
@@ -449,6 +450,16 @@ class TimeTrackingService:
|
||||
if not is_admin and entry.user_id != user_id:
|
||||
return {"success": False, "message": "Access denied", "error": "access_denied"}
|
||||
|
||||
# Optimistic concurrency (optional): mobile / API clients send last known updated_at
|
||||
if expected_updated_at is not None and entry.updated_at is not None:
|
||||
if abs((entry.updated_at - expected_updated_at).total_seconds()) > 2:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Time entry was modified on the server. Refresh and try again.",
|
||||
"error": "conflict",
|
||||
"entry": entry,
|
||||
}
|
||||
|
||||
# Block non-admin edits in closed periods
|
||||
if (not is_admin) and self._is_locked_period(
|
||||
entry.user_id, entry.start_time, entry.end_time or entry.start_time
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
/* Invoice / quote edit (#574): stronger neutral borders on tinted row backgrounds; skip validation states */
|
||||
#editInvoiceForm .form-input:not(.is-invalid):not(.is-valid),
|
||||
#quote-form .form-input:not(.is-invalid):not(.is-valid) {
|
||||
@apply border-gray-400 dark:border-gray-500;
|
||||
@apply border-gray-400 dark:border-gray-500 shadow-none;
|
||||
}
|
||||
|
||||
/* Row already has border + optional hover shadow; drop input shadow and top margin to avoid a double line (#574 follow-up) */
|
||||
|
||||
@@ -75,7 +75,16 @@
|
||||
<tbody>
|
||||
{% for item in quote.items %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-3" data-label="{{ _('Description') }}">{{ item.description }}</td>
|
||||
<td class="p-3" data-label="{{ _('Description') }}">
|
||||
{% if item.display_name %}
|
||||
<span class="font-medium">{{ item.display_name }}</span>
|
||||
{% if item.description and item.description != item.display_name and item.description != '-' %}<br><span class="text-sm opacity-80">{{ item.description }}</span>{% endif %}
|
||||
{% else %}
|
||||
{{ item.description }}
|
||||
{% endif %}
|
||||
{% if item.line_kind == 'expense' and item.category %}<br><span class="text-xs opacity-70">{{ item.category }}</span>{% endif %}
|
||||
{% if item.line_kind == 'good' and item.sku %}<br><span class="text-xs opacity-70">{{ _('SKU') }}: {{ item.sku }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="p-3" data-label="{{ _('Quantity') }}">{{ "%.2f"|format(item.quantity) }} {% if item.unit %}{{ item.unit }}{% endif %}</td>
|
||||
<td class="p-3" data-label="{{ _('Unit Price') }}">{{ "%.2f"|format(item.unit_price) }} {{ quote.currency_code }}</td>
|
||||
<td class="p-3 font-medium" data-label="{{ _('Total') }}">{{ "%.2f"|format(item.total_amount) }} {{ quote.currency_code }}</td>
|
||||
|
||||
@@ -37,7 +37,32 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- OAuth Credentials Setup Section (Admin only, not for CalDAV or ActivityWatch) -->
|
||||
{% if current_user.is_admin and provider not in ('caldav_calendar', 'activitywatch') %}
|
||||
{% if current_user.is_admin and provider == 'linear' and integration %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
<i class="fas fa-key mr-2"></i>{{ _('Linear API Key') }}
|
||||
</h2>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="action" value="update_linear_api_key">
|
||||
<div>
|
||||
<label for="linear_api_key" class="block text-sm font-medium mb-2">
|
||||
{{ _('Personal API Key') }} <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input type="password" name="linear_api_key" id="linear_api_key"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
|
||||
placeholder="{{ _('Create at linear.app/settings/api') }}"
|
||||
autocomplete="off">
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('From Linear: Settings → API → Personal API keys') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="mt-4 bg-primary text-white px-4 py-2 rounded-lg">{{ _('Save API Key') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.is_admin and provider not in ('caldav_calendar', 'activitywatch', 'linear') %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm">
|
||||
<h2 class="text-lg font-semibold mb-4">
|
||||
<i class="fas fa-key mr-2"></i>{{ _('OAuth Credentials Setup') }}
|
||||
|
||||
@@ -73,12 +73,12 @@
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-xl border border-border-light dark:border-border-dark shadow-sm mt-6">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Sync History') }}</h2>
|
||||
{% if recent_events %}
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<div class="space-y-2 max-h-[32rem] overflow-y-auto">
|
||||
{% for event in recent_events %}
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-2 last:border-0">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-text-light dark:text-text-dark">{{ event.event_type|replace('_', ' ')|title }}</span>
|
||||
{% if event.status == 'success' %}
|
||||
<span class="px-2 py-0.5 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Success') }}</span>
|
||||
@@ -89,7 +89,13 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if event.message %}
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ event.message }}</p>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1 break-words">{{ event.message }}</p>
|
||||
{% endif %}
|
||||
{% if event.event_metadata %}
|
||||
<details class="mt-2">
|
||||
<summary class="text-xs text-primary cursor-pointer hover:underline">{{ _('Details') }}</summary>
|
||||
<pre class="mt-2 text-xs bg-gray-100 dark:bg-gray-900 p-2 rounded overflow-x-auto max-h-48 overflow-y-auto">{{ event.event_metadata | tojson }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ event.created_at|user_datetime }}</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const itemsContainer = document.getElementById('quote-items');
|
||||
const expensesContainer = document.getElementById('quote-expenses');
|
||||
const goodsContainer = document.getElementById('quote-goods');
|
||||
const addItemBtn = document.getElementById('add-item');
|
||||
const addExpBtn = document.getElementById('add-quote-expense');
|
||||
const addGoodBtn = document.getElementById('add-quote-good');
|
||||
|
||||
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
|
||||
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
|
||||
|
||||
function stockOptionsHtml(selectedId) {
|
||||
let h = '<option value="">{{ _("None") }}</option>';
|
||||
if (stockItems && Array.isArray(stockItems)) {
|
||||
stockItems.forEach(function(si) {
|
||||
const sel = String(si.id) === String(selectedId || '') ? ' selected' : '';
|
||||
const desc = String(si.description || si.name || '').replace(/"/g, '"');
|
||||
h += '<option value="' + si.id + '" data-price="' + (si.default_price || 0) + '" data-unit="' + (si.unit || '') + '" data-description="' + desc + '"' + sel + '>' + si.sku + ' - ' + si.name + '</option>';
|
||||
});
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function warehouseOptionsHtml(selectedId) {
|
||||
let h = '<option value="">{{ _("None") }}</option>';
|
||||
if (warehouses && Array.isArray(warehouses)) {
|
||||
warehouses.forEach(function(wh) {
|
||||
const sel = String(wh.id) === String(selectedId || '') ? ' selected' : '';
|
||||
h += '<option value="' + wh.id + '"' + sel + '>' + wh.code + ' - ' + wh.name + '</option>';
|
||||
});
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function applyItemRowLayout(row) {
|
||||
const src = row.querySelector('.item-line-source');
|
||||
const stockMode = src && src.value === 'stock';
|
||||
const stockCols = row.querySelector('.item-stock-cols');
|
||||
const descWrap = row.querySelector('.item-desc-wrap');
|
||||
if (!stockCols || !descWrap) return;
|
||||
if (stockMode) {
|
||||
stockCols.classList.remove('hidden');
|
||||
descWrap.classList.remove('md:col-span-6');
|
||||
descWrap.classList.add('md:col-span-2');
|
||||
} else {
|
||||
stockCols.classList.add('hidden');
|
||||
const hidS = row.querySelector('input[name="item_stock_item_id[]"]');
|
||||
const hidW = row.querySelector('input[name="item_warehouse_id[]"]');
|
||||
if (hidS) hidS.value = '';
|
||||
if (hidW) hidW.value = '';
|
||||
const ss = row.querySelector('.item-stock-select');
|
||||
const ws = row.querySelector('.item-warehouse-select');
|
||||
if (ss) ss.value = '';
|
||||
if (ws) ws.value = '';
|
||||
descWrap.classList.remove('md:col-span-2');
|
||||
descWrap.classList.add('md:col-span-6');
|
||||
}
|
||||
}
|
||||
|
||||
function wireItemRow(row) {
|
||||
const src = row.querySelector('.item-line-source');
|
||||
if (src) {
|
||||
src.addEventListener('change', function() {
|
||||
applyItemRowLayout(row);
|
||||
calculateTotals();
|
||||
});
|
||||
}
|
||||
const ss = row.querySelector('.item-stock-select');
|
||||
if (ss) {
|
||||
ss.addEventListener('change', function() {
|
||||
const hid = row.querySelector('input[name="item_stock_item_id[]"]');
|
||||
if (hid) hid.value = this.value || '';
|
||||
const opt = this.options[this.selectedIndex];
|
||||
if (this.value && opt) {
|
||||
const price = parseFloat(opt.dataset.price || 0);
|
||||
const description = opt.dataset.description || '';
|
||||
const unit = opt.dataset.unit || '';
|
||||
const dEl = row.querySelector('.item-description');
|
||||
if (dEl && description && !dEl.value) dEl.value = description;
|
||||
const pEl = row.querySelector('.item-price');
|
||||
if (pEl && price > 0 && !pEl.value) pEl.value = price.toFixed(2);
|
||||
const uEl = row.querySelector('.item-unit');
|
||||
if (uEl && unit && !uEl.value) uEl.value = unit;
|
||||
}
|
||||
calculateTotals();
|
||||
});
|
||||
}
|
||||
const ws = row.querySelector('.item-warehouse-select');
|
||||
if (ws) {
|
||||
ws.addEventListener('change', function() {
|
||||
const hid = row.querySelector('input[name="item_warehouse_id[]"]');
|
||||
if (hid) hid.value = this.value || '';
|
||||
});
|
||||
}
|
||||
row.querySelector('.remove-item')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
applyItemRowLayout(row);
|
||||
}
|
||||
|
||||
function addItemRow() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200/50 dark:border-blue-800/50 quote-item-row min-w-0 hover:shadow-sm transition';
|
||||
row.innerHTML =
|
||||
'<input type="hidden" name="item_id[]" value="">' +
|
||||
'<input type="hidden" name="item_stock_item_id[]" value="">' +
|
||||
'<input type="hidden" name="item_warehouse_id[]" value="">' +
|
||||
'<div class="md:col-span-2 min-w-0">' +
|
||||
'<select name="item_line_source[]" class="form-input item-line-source text-sm">' +
|
||||
'<option value="manual" selected>{{ _("Manual entry") }}</option>' +
|
||||
'<option value="stock">{{ _("From stock") }}</option></select></div>' +
|
||||
'<div class="item-stock-cols md:col-span-4 min-w-0 hidden grid grid-cols-1 md:grid-cols-2 gap-3">' +
|
||||
'<select class="form-input item-stock-select text-sm">' + stockOptionsHtml() + '</select>' +
|
||||
'<select class="form-input item-warehouse-select text-sm">' + warehouseOptionsHtml() + '</select></div>' +
|
||||
'<div class="item-desc-wrap md:col-span-6 min-w-0">' +
|
||||
'<input type="text" name="item_description[]" class="w-full form-input item-description" placeholder="{{ _("Item description") }}" data-calc-trigger></div>' +
|
||||
'<input type="number" name="item_quantity[]" value="1" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>' +
|
||||
'<input type="text" name="item_unit[]" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="{{ _("Unit") }}">' +
|
||||
'<input type="number" name="item_price[]" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-price" data-calc-trigger>' +
|
||||
'<div class="md:col-span-1 min-w-0 flex items-center justify-between gap-2">' +
|
||||
'<span class="font-medium item-total">0.00</span>' +
|
||||
'<button type="button" class="remove-item shrink-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200" title="{{ _("Remove item") }}"><i class="fas fa-trash"></i></button></div>';
|
||||
itemsContainer.appendChild(row);
|
||||
wireItemRow(row);
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
const qeCategoryOptions =
|
||||
'<option value="travel">{{ _("Travel") }}</option>' +
|
||||
'<option value="meals">{{ _("Meals") }}</option>' +
|
||||
'<option value="supplies">{{ _("Supplies") }}</option>' +
|
||||
'<option value="services">{{ _("Services") }}</option>' +
|
||||
'<option value="equipment">{{ _("Equipment") }}</option>' +
|
||||
'<option value="other" selected>{{ _("Other") }}</option>';
|
||||
|
||||
function addExpenseRow() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition';
|
||||
row.innerHTML =
|
||||
'<input type="hidden" name="qe_id[]" value="">' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _("Title") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><select name="qe_category[]" class="form-input">' + qeCategoryOptions + '</select></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="number" name="qe_amount[]" class="form-input qe-amount" step="0.01" min="0" placeholder="0" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="date" name="qe_date[]" class="form-input user-date-input text-sm"></div>' +
|
||||
'<div class="md:col-span-1 min-w-0 flex items-center justify-center">' +
|
||||
'<button type="button" class="remove-quote-expense bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200" title="{{ _("Remove") }}"><i class="fas fa-trash"></i></button></div>';
|
||||
expensesContainer.appendChild(row);
|
||||
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
const qgCategoryOptions =
|
||||
'<option value="product">{{ _("Product") }}</option>' +
|
||||
'<option value="service">{{ _("Service") }}</option>' +
|
||||
'<option value="material">{{ _("Material") }}</option>' +
|
||||
'<option value="license">{{ _("License") }}</option>' +
|
||||
'<option value="other" selected>{{ _("Other") }}</option>';
|
||||
|
||||
function addGoodRow() {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition';
|
||||
row.innerHTML =
|
||||
'<input type="hidden" name="qg_id[]" value="">' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _("Name") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _("Description") }}" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><select name="qg_category[]" class="form-input">' + qgCategoryOptions + '</select></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="number" name="qg_quantity[]" class="form-input qg-quantity" value="1" step="0.01" min="0" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-2 min-w-0"><input type="number" name="qg_unit_price[]" class="form-input qg-price" step="0.01" min="0" data-calc-trigger></div>' +
|
||||
'<div class="md:col-span-1 min-w-0"><input type="text" name="qg_sku[]" class="form-input text-xs" placeholder="{{ _("SKU") }}"></div>' +
|
||||
'<div class="md:col-span-1 min-w-0 flex items-center justify-center">' +
|
||||
'<button type="button" class="remove-quote-good bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200" title="{{ _("Remove") }}"><i class="fas fa-trash"></i></button></div>';
|
||||
goodsContainer.appendChild(row);
|
||||
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
function calculateTotals() {
|
||||
let itemsTotal = 0;
|
||||
let itemsCount = 0;
|
||||
document.querySelectorAll('.quote-item-row').forEach(function(row) {
|
||||
const qty = parseFloat(row.querySelector('.item-quantity')?.value || 0);
|
||||
const price = parseFloat(row.querySelector('.item-price')?.value || 0);
|
||||
const total = qty * price;
|
||||
const totalEl = row.querySelector('.item-total');
|
||||
if (totalEl) totalEl.textContent = total.toFixed(2);
|
||||
if (qty > 0) {
|
||||
itemsTotal += total;
|
||||
const desc = row.querySelector('.item-description')?.value?.trim();
|
||||
const sid = row.querySelector('input[name="item_stock_item_id[]"]')?.value;
|
||||
if (desc || sid) itemsCount++;
|
||||
}
|
||||
});
|
||||
|
||||
let expTotal = 0;
|
||||
let expCount = 0;
|
||||
document.querySelectorAll('.quote-expense-row').forEach(function(row) {
|
||||
const amt = parseFloat(row.querySelector('.qe-amount')?.value || 0);
|
||||
const t = row.querySelector('[name="qe_title[]"]')?.value?.trim();
|
||||
const d = row.querySelector('[name="qe_description[]"]')?.value?.trim();
|
||||
if (amt > 0) expTotal += amt;
|
||||
if (amt > 0 || t || d) expCount++;
|
||||
});
|
||||
|
||||
let goodsTotal = 0;
|
||||
let goodsCount = 0;
|
||||
document.querySelectorAll('.quote-good-row').forEach(function(row) {
|
||||
const qty = parseFloat(row.querySelector('.qg-quantity')?.value || 0);
|
||||
const price = parseFloat(row.querySelector('.qg-price')?.value || 0);
|
||||
const nm = row.querySelector('[name="qg_name[]"]')?.value?.trim();
|
||||
const ds = row.querySelector('[name="qg_description[]"]')?.value?.trim();
|
||||
if (qty > 0 && price > 0) goodsTotal += qty * price;
|
||||
if ((qty > 0 && price > 0) || nm || ds) goodsCount++;
|
||||
});
|
||||
|
||||
const grand = itemsTotal + expTotal + goodsTotal;
|
||||
const el = (id) => document.getElementById(id);
|
||||
if (el('quote-items-section-total')) el('quote-items-section-total').textContent = itemsTotal.toFixed(2);
|
||||
if (el('quote-expenses-section-total')) el('quote-expenses-section-total').textContent = expTotal.toFixed(2);
|
||||
if (el('quote-goods-section-total')) el('quote-goods-section-total').textContent = goodsTotal.toFixed(2);
|
||||
if (el('quote-all-lines-subtotal')) el('quote-all-lines-subtotal').textContent = grand.toFixed(2);
|
||||
if (el('items-count')) el('items-count').textContent = itemsCount;
|
||||
if (el('quote-expenses-count')) el('quote-expenses-count').textContent = expCount;
|
||||
if (el('quote-goods-count')) el('quote-goods-count').textContent = goodsCount;
|
||||
}
|
||||
|
||||
addItemBtn?.addEventListener('click', addItemRow);
|
||||
addExpBtn?.addEventListener('click', addExpenseRow);
|
||||
addGoodBtn?.addEventListener('click', addGoodRow);
|
||||
|
||||
document.querySelectorAll('.quote-item-row').forEach(wireItemRow);
|
||||
document.querySelectorAll('.quote-expense-row').forEach(function(row) {
|
||||
row.querySelector('.remove-quote-expense')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('.quote-good-row').forEach(function(row) {
|
||||
row.querySelector('.remove-quote-good')?.addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(function(el) {
|
||||
el.addEventListener('input', calculateTotals);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
|
||||
calculateTotals();
|
||||
});
|
||||
</script>
|
||||
@@ -43,42 +43,106 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote Items Section -->
|
||||
<!-- Line items (manual or from stock — issue #585) -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4 pb-2 border-b border-border-light dark:border-border-dark">
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-list mr-2 text-primary"></i>{{ _('Quote Items') }}
|
||||
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">0</span>
|
||||
</h2>
|
||||
<button type="button" id="add-item" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-list mr-2 text-blue-600"></i>{{ _('Quote line items') }}
|
||||
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">0</span>
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Services, labor, and catalog lines') }}</p>
|
||||
</div>
|
||||
<button type="button" id="add-item" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add line') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items header (desktop) -->
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Stock Item') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Line type') }}</div>
|
||||
<div class="md:col-span-4">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Quantity') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Qty') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Unit') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Unit Price') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Price') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Total') }}</div>
|
||||
</div>
|
||||
<div id="quote-items" class="space-y-2"></div>
|
||||
<div class="mt-3 p-3 bg-blue-50/30 dark:bg-blue-950/10 rounded-lg border border-blue-200/30 dark:border-blue-800/30">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Line items subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-blue-700 dark:text-blue-400"><span id="quote-items-section-total">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-receipt mr-2 text-amber-600"></i>{{ _('Costs') }}
|
||||
<span id="quote-expenses-count" class="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full">0</span>
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('One-off costs (e.g. travel, materials)') }}</p>
|
||||
</div>
|
||||
<button type="button" id="add-quote-expense" class="btn bg-amber-600 text-white hover:bg-amber-700 shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add cost') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Title') }}</div>
|
||||
<div class="md:col-span-3">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Amount') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Date') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="quote-items" class="space-y-2">
|
||||
<!-- Items will be added here dynamically -->
|
||||
</div>
|
||||
|
||||
<div id="items-subtotal" class="mt-3 p-3 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<div id="quote-expenses" class="space-y-2"></div>
|
||||
<div class="mt-3 p-3 bg-amber-50/30 dark:bg-amber-950/10 rounded-lg border border-amber-200/30 dark:border-amber-800/30">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-primary"><span id="items-subtotal-amount">0.00</span></span>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Costs subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-amber-700 dark:text-amber-400"><span id="quote-expenses-section-total">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-box mr-2 text-emerald-600"></i>{{ _('Extra goods') }}
|
||||
<span id="quote-goods-count" class="ml-2 px-2 py-0.5 text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-full">0</span>
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Products, licenses, hardware') }}</p>
|
||||
</div>
|
||||
<button type="button" id="add-quote-good" class="btn bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add good') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Name') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Qty') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Price') }}</div>
|
||||
<div class="md:col-span-1">{{ _('SKU') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
|
||||
</div>
|
||||
<div id="quote-goods" class="space-y-2"></div>
|
||||
<div class="mt-3 p-3 bg-emerald-50/30 dark:bg-emerald-950/10 rounded-lg border border-emerald-200/30 dark:border-emerald-800/30">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Goods subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-emerald-700 dark:text-emerald-400"><span id="quote-goods-section-total">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 p-4 rounded-xl border border-border-light dark:border-border-dark bg-gray-50 dark:bg-gray-900/40">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span>{{ _('Subtotal (all quote lines)') }}</span>
|
||||
<span class="text-xl font-bold text-primary"><span id="quote-all-lines-subtotal">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Details Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 pb-2 border-b border-border-light dark:border-border-dark flex items-center">
|
||||
@@ -204,175 +268,13 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
{% include 'quotes/_edit_quote_form_scripts.html' %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const itemsContainer = document.getElementById('quote-items');
|
||||
const addItemBtn = document.getElementById('add-item');
|
||||
let itemIndex = 0;
|
||||
|
||||
// Stock items and warehouses data
|
||||
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
|
||||
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
|
||||
|
||||
// Handle stock item selection
|
||||
function setupStockItemHandlers() {
|
||||
document.querySelectorAll('.item-stock-select').forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
const row = this.closest('.quote-item-row');
|
||||
const stockItemId = this.value;
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
|
||||
|
||||
hiddenStockInput.value = stockItemId || '';
|
||||
|
||||
if (stockItemId && selectedOption) {
|
||||
const price = parseFloat(selectedOption.dataset.price || 0);
|
||||
const description = selectedOption.dataset.description || '';
|
||||
const unit = selectedOption.dataset.unit || '';
|
||||
|
||||
// Auto-populate fields
|
||||
if (description && !row.querySelector('.item-description').value) {
|
||||
row.querySelector('.item-description').value = description;
|
||||
}
|
||||
if (price > 0 && !row.querySelector('.item-price').value) {
|
||||
row.querySelector('.item-price').value = price.toFixed(2);
|
||||
}
|
||||
if (unit && !row.querySelector('.item-unit').value) {
|
||||
row.querySelector('.item-unit').value = unit;
|
||||
}
|
||||
calculateTotals();
|
||||
}
|
||||
});
|
||||
});
|
||||
var qi = document.getElementById('quote-items');
|
||||
if (qi && qi.children.length === 0) {
|
||||
document.getElementById('add-item')?.click();
|
||||
}
|
||||
|
||||
// Add new item row
|
||||
function addItemRow(item = null) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row hover:shadow-sm transition';
|
||||
|
||||
// Build stock items dropdown
|
||||
let stockItemsHtml = '<option value="">{{ _("None") }}</option>';
|
||||
if (stockItems && Array.isArray(stockItems)) {
|
||||
stockItems.forEach(stockItem => {
|
||||
const price = stockItem.default_price || 0;
|
||||
const unit = stockItem.unit || '';
|
||||
const desc = stockItem.description || stockItem.name || '';
|
||||
stockItemsHtml += '<option value="' + stockItem.id + '" data-price="' + price + '" data-unit="' + unit + '" data-description="' + desc.replace(/"/g, '"') + '">' + stockItem.sku + ' - ' + stockItem.name + '</option>';
|
||||
});
|
||||
}
|
||||
|
||||
// Build warehouses dropdown
|
||||
let warehousesHtml = '<option value="">{{ _("None") }}</option>';
|
||||
if (warehouses && Array.isArray(warehouses)) {
|
||||
warehouses.forEach(wh => {
|
||||
warehousesHtml += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
|
||||
});
|
||||
}
|
||||
|
||||
// Translated strings
|
||||
const placeholderDesc = '{{ _("Item description") }}';
|
||||
const placeholderQty = '{{ _("Qty") }}';
|
||||
const placeholderUnit = '{{ _("Unit") }}';
|
||||
const placeholderPrice = '{{ _("Price") }}';
|
||||
const removeTitle = '{{ _("Remove item") }}';
|
||||
|
||||
row.innerHTML =
|
||||
'<input type="hidden" name="item_id[]" value="' + (item ? item.id : '') + '">' +
|
||||
'<input type="hidden" name="item_stock_item_id[]" value="">' +
|
||||
'<input type="hidden" name="item_warehouse_id[]" value="">' +
|
||||
'<select class="md:col-span-2 form-input item-stock-select text-sm">' + stockItemsHtml + '</select>' +
|
||||
'<select class="md:col-span-2 form-input item-warehouse-select text-sm">' + warehousesHtml + '</select>' +
|
||||
'<input type="text" name="item_description[]" placeholder="' + placeholderDesc + '" value="' + (item ? (item.description || '').replace(/"/g, '"') : '') + '" class="md:col-span-2 form-input item-description" data-calc-trigger>' +
|
||||
'<input type="number" name="item_quantity[]" placeholder="' + placeholderQty + '" value="' + (item ? item.quantity : '1') + '" step="0.01" min="0" class="md:col-span-1 form-input item-quantity" data-calc-trigger>' +
|
||||
'<input type="text" name="item_unit[]" placeholder="' + placeholderUnit + '" value="' + (item ? (item.unit || '') : '') + '" class="md:col-span-1 form-input item-unit" placeholder="hrs, pcs, etc.">' +
|
||||
'<input type="number" name="item_price[]" placeholder="' + placeholderPrice + '" value="' + (item ? item.unit_price : '') + '" step="0.01" min="0" class="md:col-span-2 form-input item-price" data-calc-trigger>' +
|
||||
'<div class="md:col-span-1 flex items-center font-medium item-total">0.00</div>' +
|
||||
'<button type="button" class="remove-item md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="' + removeTitle + '">' +
|
||||
'<i class="fas fa-trash"></i>' +
|
||||
'</button>';
|
||||
itemsContainer.appendChild(row);
|
||||
|
||||
// Setup handlers for new row
|
||||
const stockSelect = row.querySelector('.item-stock-select');
|
||||
stockSelect.addEventListener('change', function() {
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
|
||||
hiddenStockInput.value = this.value || '';
|
||||
|
||||
if (this.value && selectedOption) {
|
||||
const price = parseFloat(selectedOption.dataset.price || 0);
|
||||
const description = selectedOption.dataset.description || '';
|
||||
const unit = selectedOption.dataset.unit || '';
|
||||
if (description) row.querySelector('.item-description').value = description;
|
||||
if (price > 0) row.querySelector('.item-price').value = price.toFixed(2);
|
||||
if (unit) row.querySelector('.item-unit').value = unit;
|
||||
calculateTotals();
|
||||
}
|
||||
});
|
||||
|
||||
const warehouseSelect = row.querySelector('.item-warehouse-select');
|
||||
warehouseSelect.addEventListener('change', function() {
|
||||
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
|
||||
hiddenWarehouseInput.value = this.value || '';
|
||||
});
|
||||
|
||||
// Add event listeners
|
||||
row.querySelector('.remove-item').addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
|
||||
// Add calculation triggers
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
|
||||
input.addEventListener('input', calculateTotals);
|
||||
});
|
||||
|
||||
setupStockItemHandlers();
|
||||
itemIndex++;
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
// Calculate totals
|
||||
function calculateTotals() {
|
||||
let itemsTotal = 0;
|
||||
let itemsCount = 0;
|
||||
|
||||
document.querySelectorAll('.quote-item-row').forEach(row => {
|
||||
const qty = parseFloat(row.querySelector('.item-quantity')?.value || 0);
|
||||
const price = parseFloat(row.querySelector('.item-price')?.value || 0);
|
||||
const total = qty * price;
|
||||
|
||||
if (qty > 0 && price > 0) {
|
||||
itemsTotal += total;
|
||||
itemsCount++;
|
||||
}
|
||||
|
||||
// Update row total
|
||||
const totalEl = row.querySelector('.item-total');
|
||||
if (totalEl) {
|
||||
totalEl.textContent = total.toFixed(2);
|
||||
}
|
||||
});
|
||||
|
||||
// Update subtotal
|
||||
document.getElementById('items-subtotal-amount').textContent = itemsTotal.toFixed(2);
|
||||
document.getElementById('items-count').textContent = itemsCount;
|
||||
}
|
||||
|
||||
// Add item button
|
||||
addItemBtn.addEventListener('click', function() {
|
||||
addItemRow();
|
||||
});
|
||||
|
||||
// Initialize stock item handlers
|
||||
setupStockItemHandlers();
|
||||
|
||||
// Add initial empty row
|
||||
addItemRow();
|
||||
|
||||
// Calculate on tax rate change
|
||||
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
+161
-232
@@ -48,68 +48,188 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quote Items Section -->
|
||||
<!-- Line items (manual or from stock — issue #585) -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4 pb-2 border-b border-border-light dark:border-border-dark">
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-list mr-2 text-primary"></i>{{ _('Quote Items') }}
|
||||
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-primary/10 text-primary rounded-full">{{ quote.items|length if quote.items else 0 }}</span>
|
||||
</h2>
|
||||
<button type="button" id="add-item" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add Item') }}
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-list mr-2 text-blue-600"></i>{{ _('Quote line items') }}
|
||||
<span id="items-count" class="ml-2 px-2 py-0.5 text-xs bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-full">0</span>
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Services, labor, and catalog lines') }}</p>
|
||||
</div>
|
||||
<button type="button" id="add-item" class="btn btn-primary shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add line') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Items header (desktop) -->
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Stock Item') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Line type') }}</div>
|
||||
<div class="md:col-span-4 item-stock-header">{{ _('Stock') }} / {{ _('Warehouse') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Quantity') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Qty') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Unit') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Unit Price') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Price') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Total') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="quote-items" class="space-y-2">
|
||||
{% for item in quote.items %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row min-w-0 hover:shadow-sm transition">
|
||||
{% for item in quote.items if (item.line_kind or 'item') == 'item' %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-blue-50/50 dark:bg-blue-950/20 border border-blue-200/50 dark:border-blue-800/50 quote-item-row min-w-0 hover:shadow-sm transition">
|
||||
<input type="hidden" name="item_id[]" value="{{ item.id }}">
|
||||
<input type="hidden" name="item_stock_item_id[]" value="{{ item.stock_item_id if item.stock_item_id else '' }}">
|
||||
<input type="hidden" name="item_warehouse_id[]" value="{{ item.warehouse_id if item.warehouse_id else '' }}">
|
||||
<select class="md:col-span-2 min-w-0 form-input item-stock-select text-sm" title="{{ _('Select Stock Item') }}">
|
||||
<option value="">{{ _('None') }}</option>
|
||||
{% for stock_item in stock_items %}
|
||||
<option value="{{ stock_item.id }}" data-price="{{ stock_item.default_price or 0 }}" data-unit="{{ stock_item.unit }}" data-description="{{ stock_item.name }}" {% if item.stock_item_id == stock_item.id %}selected{% endif %}>{{ stock_item.sku }} - {{ stock_item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select class="md:col-span-2 min-w-0 form-input item-warehouse-select text-sm" title="{{ _('Select Warehouse') }}">
|
||||
<option value="">{{ _('None') }}</option>
|
||||
{% for warehouse in warehouses %}
|
||||
<option value="{{ warehouse.id }}" {% if item.warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="md:col-span-2 min-w-0 form-input item-description" data-calc-trigger>
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<select name="item_line_source[]" class="form-input item-line-source text-sm" title="{{ _('Line type') }}">
|
||||
<option value="manual" {% if not item.stock_item_id %}selected{% endif %}>{{ _('Manual entry') }}</option>
|
||||
<option value="stock" {% if item.stock_item_id %}selected{% endif %}>{{ _('From stock') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="item-stock-cols md:col-span-4 min-w-0 grid grid-cols-1 md:grid-cols-2 gap-3 {% if not item.stock_item_id %}hidden{% endif %}">
|
||||
<select class="form-input item-stock-select text-sm" title="{{ _('Select Stock Item') }}">
|
||||
<option value="">{{ _('None') }}</option>
|
||||
{% for stock_item in stock_items %}
|
||||
<option value="{{ stock_item.id }}" data-price="{{ stock_item.default_price or 0 }}" data-unit="{{ stock_item.unit or '' }}" data-description="{{ stock_item.name }}" {% if item.stock_item_id == stock_item.id %}selected{% endif %}>{{ stock_item.sku }} - {{ stock_item.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select class="form-input item-warehouse-select text-sm" title="{{ _('Select Warehouse') }}">
|
||||
<option value="">{{ _('None') }}</option>
|
||||
{% for warehouse in warehouses %}
|
||||
<option value="{{ warehouse.id }}" {% if item.warehouse_id == warehouse.id %}selected{% endif %}>{{ warehouse.code }} - {{ warehouse.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="item-desc-wrap md:col-span-2 min-w-0 {% if not item.stock_item_id %}md:col-span-6{% endif %}">
|
||||
<input type="text" name="item_description[]" placeholder="{{ _('Item description') }}" value="{{ item.description }}" class="w-full form-input item-description" data-calc-trigger>
|
||||
</div>
|
||||
<input type="number" name="item_quantity[]" placeholder="{{ _('Qty') }}" value="{{ item.quantity }}" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>
|
||||
<input type="text" name="item_unit[]" placeholder="{{ _('Unit') }}" value="{{ item.unit or '' }}" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="hrs, pcs, etc.">
|
||||
<input type="number" name="item_price[]" placeholder="{{ _('Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-2 min-w-0 form-input item-price" data-calc-trigger>
|
||||
<div class="md:col-span-1 min-w-0 flex items-center font-medium item-total">{{ "%.2f"|format(item.total_amount) }}</div>
|
||||
<button type="button" class="remove-item md:col-span-1 min-w-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove item') }}">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
<input type="text" name="item_unit[]" placeholder="{{ _('Unit') }}" value="{{ item.unit or '' }}" class="md:col-span-1 min-w-0 form-input item-unit">
|
||||
<input type="number" name="item_price[]" placeholder="{{ _('Price') }}" value="{{ item.unit_price }}" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-price" data-calc-trigger>
|
||||
<div class="md:col-span-1 min-w-0 flex items-center justify-between gap-2">
|
||||
<span class="font-medium item-total">{{ "%.2f"|format(item.total_amount) }}</span>
|
||||
<button type="button" class="remove-item shrink-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove item') }}"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="items-subtotal" class="mt-3 p-3 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<div class="mt-3 p-3 bg-blue-50/30 dark:bg-blue-950/10 rounded-lg border border-blue-200/30 dark:border-blue-800/30">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-primary"><span id="items-subtotal-amount">{{ "%.2f"|format(quote.subtotal) if quote.subtotal else '0.00' }}</span></span>
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Line items subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-blue-700 dark:text-blue-400"><span id="quote-items-section-total">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Costs / expenses (quote) -->
|
||||
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-receipt mr-2 text-amber-600"></i>{{ _('Costs') }}
|
||||
<span id="quote-expenses-count" class="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full">0</span>
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('One-off costs (e.g. travel, materials)') }}</p>
|
||||
</div>
|
||||
<button type="button" id="add-quote-expense" class="btn bg-amber-600 text-white hover:bg-amber-700 shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add cost') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Title') }}</div>
|
||||
<div class="md:col-span-3">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Amount') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Date') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
|
||||
</div>
|
||||
<div id="quote-expenses" class="space-y-2">
|
||||
{% for item in quote.items if (item.line_kind or 'item') == 'expense' %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 quote-expense-row min-w-0 hover:shadow-sm transition">
|
||||
<input type="hidden" name="qe_id[]" value="{{ item.id }}">
|
||||
<div class="md:col-span-2 min-w-0"><input type="text" name="qe_title[]" class="form-input" placeholder="{{ _('Title') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-3 min-w-0"><input type="text" name="qe_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<select name="qe_category[]" class="form-input">
|
||||
<option value="travel" {% if item.category == 'travel' %}selected{% endif %}>{{ _('Travel') }}</option>
|
||||
<option value="meals" {% if item.category == 'meals' %}selected{% endif %}>{{ _('Meals') }}</option>
|
||||
<option value="supplies" {% if item.category == 'supplies' %}selected{% endif %}>{{ _('Supplies') }}</option>
|
||||
<option value="services" {% if item.category == 'services' %}selected{% endif %}>{{ _('Services') }}</option>
|
||||
<option value="equipment" {% if item.category == 'equipment' %}selected{% endif %}>{{ _('Equipment') }}</option>
|
||||
<option value="other" {% if (item.category or 'other') == 'other' %}selected{% endif %}>{{ _('Other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="number" name="qe_amount[]" class="form-input qe-amount" step="0.01" min="0" value="{{ item.unit_price }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="date" name="qe_date[]" class="form-input user-date-input text-sm" value="{{ item.line_date.strftime('%Y-%m-%d') if item.line_date else '' }}"></div>
|
||||
<div class="md:col-span-1 min-w-0 flex items-center justify-center"><button type="button" class="remove-quote-expense bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove') }}"><i class="fas fa-trash"></i></button></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-3 p-3 bg-amber-50/30 dark:bg-amber-950/10 rounded-lg border border-amber-200/30 dark:border-amber-800/30">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Costs subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-amber-700 dark:text-amber-400"><span id="quote-expenses-section-total">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Extra goods -->
|
||||
<div class="mb-8 mt-8 border-t border-border-light dark:border-border-dark pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center">
|
||||
<i class="fas fa-box mr-2 text-emerald-600"></i>{{ _('Extra goods') }}
|
||||
<span id="quote-goods-count" class="ml-2 px-2 py-0.5 text-xs bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-full">0</span>
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Products, licenses, hardware') }}</p>
|
||||
</div>
|
||||
<button type="button" id="add-quote-good" class="btn bg-emerald-600 text-white hover:bg-emerald-700 shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add good') }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-2">{{ _('Name') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Qty') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Price') }}</div>
|
||||
<div class="md:col-span-1">{{ _('SKU') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
|
||||
</div>
|
||||
<div id="quote-goods" class="space-y-2">
|
||||
{% for item in quote.items if (item.line_kind or 'item') == 'good' %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-emerald-50/50 dark:bg-emerald-950/20 border border-emerald-200/50 dark:border-emerald-800/50 quote-good-row min-w-0 hover:shadow-sm transition">
|
||||
<input type="hidden" name="qg_id[]" value="{{ item.id }}">
|
||||
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_name[]" class="form-input" placeholder="{{ _('Name') }}" value="{{ item.display_name or '' }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="text" name="qg_description[]" class="form-input" placeholder="{{ _('Description') }}" value="{{ item.description }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<select name="qg_category[]" class="form-input">
|
||||
<option value="product" {% if item.category == 'product' %}selected{% endif %}>{{ _('Product') }}</option>
|
||||
<option value="service" {% if item.category == 'service' %}selected{% endif %}>{{ _('Service') }}</option>
|
||||
<option value="material" {% if item.category == 'material' %}selected{% endif %}>{{ _('Material') }}</option>
|
||||
<option value="license" {% if item.category == 'license' %}selected{% endif %}>{{ _('License') }}</option>
|
||||
<option value="other" {% if (item.category or 'other') == 'other' %}selected{% endif %}>{{ _('Other') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="number" name="qg_quantity[]" class="form-input qg-quantity" step="0.01" min="0" value="{{ item.quantity }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-2 min-w-0"><input type="number" name="qg_unit_price[]" class="form-input qg-price" step="0.01" min="0" value="{{ item.unit_price }}" data-calc-trigger></div>
|
||||
<div class="md:col-span-1 min-w-0"><input type="text" name="qg_sku[]" class="form-input text-xs" placeholder="{{ _('SKU') }}" value="{{ item.sku or '' }}"></div>
|
||||
<div class="md:col-span-1 min-w-0 flex items-center justify-center"><button type="button" class="remove-quote-good bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Remove') }}"><i class="fas fa-trash"></i></button></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-3 p-3 bg-emerald-50/30 dark:bg-emerald-950/10 rounded-lg border border-emerald-200/30 dark:border-emerald-800/30">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Goods subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-emerald-700 dark:text-emerald-400"><span id="quote-goods-section-total">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-8 p-4 rounded-xl border border-border-light dark:border-border-dark bg-gray-50 dark:bg-gray-900/40">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span>{{ _('Subtotal (all quote lines)') }}</span>
|
||||
<span class="text-xl font-bold text-primary"><span id="quote-all-lines-subtotal">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Financial Details Section -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 pb-2 border-b border-border-light dark:border-border-dark flex items-center">
|
||||
@@ -221,196 +341,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const itemsContainer = document.getElementById('quote-items');
|
||||
const addItemBtn = document.getElementById('add-item');
|
||||
let itemIndex = 0;
|
||||
|
||||
// Stock items and warehouses data
|
||||
const stockItems = {{ stock_items_json | safe if stock_items_json else '[]' }};
|
||||
const warehouses = {{ warehouses_json | safe if warehouses_json else '[]' }};
|
||||
|
||||
// Handle stock item selection
|
||||
function setupStockItemHandlers() {
|
||||
document.querySelectorAll('.item-stock-select').forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
const row = this.closest('.quote-item-row');
|
||||
const stockItemId = this.value;
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
|
||||
|
||||
hiddenStockInput.value = stockItemId || '';
|
||||
|
||||
if (stockItemId && selectedOption) {
|
||||
const price = parseFloat(selectedOption.dataset.price || 0);
|
||||
const description = selectedOption.dataset.description || '';
|
||||
const unit = selectedOption.dataset.unit || '';
|
||||
|
||||
// Auto-populate fields
|
||||
if (description && !row.querySelector('.item-description').value) {
|
||||
row.querySelector('.item-description').value = description;
|
||||
}
|
||||
if (price > 0 && !row.querySelector('.item-price').value) {
|
||||
row.querySelector('.item-price').value = price.toFixed(2);
|
||||
}
|
||||
if (unit && !row.querySelector('.item-unit').value) {
|
||||
row.querySelector('.item-unit').value = unit;
|
||||
}
|
||||
calculateTotals();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Add new item row
|
||||
function addItemRow(item = null) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-primary/5 border border-primary/20 quote-item-row min-w-0 hover:shadow-sm transition';
|
||||
|
||||
// Build stock items dropdown
|
||||
let stockItemsHtml = '<option value="">{{ _("None") }}</option>';
|
||||
if (stockItems && Array.isArray(stockItems)) {
|
||||
stockItems.forEach(stockItem => {
|
||||
const price = stockItem.default_price || 0;
|
||||
const unit = stockItem.unit || '';
|
||||
const desc = stockItem.description || stockItem.name || '';
|
||||
stockItemsHtml += '<option value="' + stockItem.id + '" data-price="' + price + '" data-unit="' + unit + '" data-description="' + desc.replace(/"/g, '"') + '">' + stockItem.sku + ' - ' + stockItem.name + '</option>';
|
||||
});
|
||||
}
|
||||
|
||||
// Build warehouses dropdown
|
||||
let warehousesHtml = '<option value="">{{ _("None") }}</option>';
|
||||
if (warehouses && Array.isArray(warehouses)) {
|
||||
warehouses.forEach(wh => {
|
||||
warehousesHtml += '<option value="' + wh.id + '">' + wh.code + ' - ' + wh.name + '</option>';
|
||||
});
|
||||
}
|
||||
|
||||
// Translated strings
|
||||
const placeholderDesc = '{{ _("Item description") }}';
|
||||
const placeholderQty = '{{ _("Qty") }}';
|
||||
const placeholderUnit = '{{ _("Unit") }}';
|
||||
const placeholderPrice = '{{ _("Price") }}';
|
||||
const removeTitle = '{{ _("Remove item") }}';
|
||||
|
||||
row.innerHTML =
|
||||
'<input type="hidden" name="item_id[]" value="' + (item ? item.id : '') + '">' +
|
||||
'<input type="hidden" name="item_stock_item_id[]" value="' + (item && item.stock_item_id ? item.stock_item_id : '') + '">' +
|
||||
'<input type="hidden" name="item_warehouse_id[]" value="' + (item && item.warehouse_id ? item.warehouse_id : '') + '">' +
|
||||
'<select class="md:col-span-2 min-w-0 form-input item-stock-select text-sm">' + stockItemsHtml + '</select>' +
|
||||
'<select class="md:col-span-2 min-w-0 form-input item-warehouse-select text-sm">' + warehousesHtml + '</select>' +
|
||||
'<input type="text" name="item_description[]" placeholder="' + placeholderDesc + '" value="' + (item ? (item.description || '').replace(/"/g, '"') : '') + '" class="md:col-span-2 min-w-0 form-input item-description" data-calc-trigger>' +
|
||||
'<input type="number" name="item_quantity[]" placeholder="' + placeholderQty + '" value="' + (item ? item.quantity : '1') + '" step="0.01" min="0" class="md:col-span-1 min-w-0 form-input item-quantity" data-calc-trigger>' +
|
||||
'<input type="text" name="item_unit[]" placeholder="' + placeholderUnit + '" value="' + (item ? (item.unit || '') : '') + '" class="md:col-span-1 min-w-0 form-input item-unit" placeholder="hrs, pcs, etc.">' +
|
||||
'<input type="number" name="item_price[]" placeholder="' + placeholderPrice + '" value="' + (item ? item.unit_price : '') + '" step="0.01" min="0" class="md:col-span-2 min-w-0 form-input item-price" data-calc-trigger>' +
|
||||
'<div class="md:col-span-1 min-w-0 flex items-center font-medium item-total">0.00</div>' +
|
||||
'<button type="button" class="remove-item md:col-span-1 min-w-0 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="' + removeTitle + '">' +
|
||||
'<i class="fas fa-trash"></i>' +
|
||||
'</button>';
|
||||
itemsContainer.appendChild(row);
|
||||
|
||||
// Setup handlers for new row
|
||||
const stockSelect = row.querySelector('.item-stock-select');
|
||||
stockSelect.addEventListener('change', function() {
|
||||
const selectedOption = this.options[this.selectedIndex];
|
||||
const hiddenStockInput = row.querySelector('input[name="item_stock_item_id[]"]');
|
||||
hiddenStockInput.value = this.value || '';
|
||||
|
||||
if (this.value && selectedOption) {
|
||||
const price = parseFloat(selectedOption.dataset.price || 0);
|
||||
const description = selectedOption.dataset.description || '';
|
||||
const unit = selectedOption.dataset.unit || '';
|
||||
if (description) row.querySelector('.item-description').value = description;
|
||||
if (price > 0) row.querySelector('.item-price').value = price.toFixed(2);
|
||||
if (unit) row.querySelector('.item-unit').value = unit;
|
||||
calculateTotals();
|
||||
}
|
||||
});
|
||||
|
||||
const warehouseSelect = row.querySelector('.item-warehouse-select');
|
||||
warehouseSelect.addEventListener('change', function() {
|
||||
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
|
||||
hiddenWarehouseInput.value = this.value || '';
|
||||
});
|
||||
|
||||
// Add event listeners
|
||||
row.querySelector('.remove-item').addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
|
||||
// Add calculation triggers
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
|
||||
input.addEventListener('input', calculateTotals);
|
||||
});
|
||||
|
||||
setupStockItemHandlers();
|
||||
itemIndex++;
|
||||
calculateTotals();
|
||||
}
|
||||
|
||||
// Initialize stock item handlers for existing rows
|
||||
setupStockItemHandlers();
|
||||
|
||||
// Setup warehouse handlers
|
||||
document.querySelectorAll('.item-warehouse-select').forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
const row = this.closest('.quote-item-row');
|
||||
const hiddenWarehouseInput = row.querySelector('input[name="item_warehouse_id[]"]');
|
||||
hiddenWarehouseInput.value = this.value || '';
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
function calculateTotals() {
|
||||
let itemsTotal = 0;
|
||||
let itemsCount = 0;
|
||||
|
||||
document.querySelectorAll('.quote-item-row').forEach(row => {
|
||||
const qty = parseFloat(row.querySelector('.item-quantity')?.value || 0);
|
||||
const price = parseFloat(row.querySelector('.item-price')?.value || 0);
|
||||
const total = qty * price;
|
||||
|
||||
if (qty > 0 && price > 0) {
|
||||
itemsTotal += total;
|
||||
itemsCount++;
|
||||
}
|
||||
|
||||
// Update row total
|
||||
const totalEl = row.querySelector('.item-total');
|
||||
if (totalEl) {
|
||||
totalEl.textContent = total.toFixed(2);
|
||||
}
|
||||
});
|
||||
|
||||
// Update subtotal
|
||||
document.getElementById('items-subtotal-amount').textContent = itemsTotal.toFixed(2);
|
||||
document.getElementById('items-count').textContent = itemsCount;
|
||||
}
|
||||
|
||||
// Add item button
|
||||
addItemBtn.addEventListener('click', function() {
|
||||
addItemRow();
|
||||
});
|
||||
|
||||
// Add event listeners to existing items
|
||||
document.querySelectorAll('.quote-item-row').forEach(row => {
|
||||
row.querySelector('.remove-item').addEventListener('click', function() {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
});
|
||||
|
||||
row.querySelectorAll('[data-calc-trigger]').forEach(input => {
|
||||
input.addEventListener('input', calculateTotals);
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate on tax rate change
|
||||
document.getElementById('tax_rate')?.addEventListener('input', calculateTotals);
|
||||
|
||||
// Initial calculation
|
||||
calculateTotals();
|
||||
});
|
||||
</script>
|
||||
{% include 'quotes/_edit_quote_form_scripts.html' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -101,7 +101,11 @@
|
||||
<tbody>
|
||||
{% for item in quote.items %}
|
||||
<tr>
|
||||
<td>{{ item.description|e }}</td>
|
||||
<td>
|
||||
{% if item.display_name %}{{ item.display_name|e }}{% if item.description and item.description != item.display_name and item.description != '-' %} — {{ item.description|e }}{% endif %}{% else %}{{ item.description|e }}{% endif %}
|
||||
{% if item.line_kind == 'expense' and item.category %} ({{ item.category|e }}){% endif %}
|
||||
{% if item.line_kind == 'good' and item.sku %} [{{ _('SKU') }}: {{ item.sku|e }}]{% endif %}
|
||||
</td>
|
||||
<td class="num">{{ '%.2f' % item.quantity }} {% if item.unit %}{{ item.unit }}{% endif %}</td>
|
||||
<td class="num">{{ format_money(item.unit_price) }}</td>
|
||||
<td class="num">{{ format_money(item.total_amount) }}</td>
|
||||
|
||||
@@ -108,7 +108,16 @@
|
||||
<tbody>
|
||||
{% for item in quote.items %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-3">{{ item.description }}</td>
|
||||
<td class="p-3">
|
||||
{% if item.display_name %}
|
||||
<div class="font-medium">{{ item.display_name }}</div>
|
||||
{% if item.description and item.description != item.display_name and item.description != '-' %}<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ item.description }}</div>{% endif %}
|
||||
{% else %}
|
||||
{{ item.description }}
|
||||
{% endif %}
|
||||
{% if item.line_kind == 'expense' and item.category %}<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ item.category }}</div>{% endif %}
|
||||
{% if item.line_kind == 'good' and item.sku %}<div class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('SKU') }}: {{ item.sku }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="p-3">{{ "%.2f"|format(item.quantity) }} {% if item.unit %}{{ item.unit }}{% endif %}</td>
|
||||
<td class="p-3">{{ "%.2f"|format(item.unit_price) }} {{ quote.currency_code }}</td>
|
||||
<td class="p-3 font-medium">{{ "%.2f"|format(item.total_amount) }} {{ quote.currency_code }}</td>
|
||||
@@ -185,7 +194,7 @@
|
||||
{% if quote.requires_approval %}
|
||||
<div class="mb-4 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<h4 class="font-medium text-yellow-800 dark:text-yellow-200 mb-2">{{ _('Approval Status') }}</h4>
|
||||
{% if quote.approval_status == 'none' %}
|
||||
{% if quote.approval_status == 'not_required' %}
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{{ _('Approval not yet requested') }}</p>
|
||||
{% if quote.status == 'draft' and (current_user.is_admin or has_permission('edit_quotes')) %}
|
||||
<form method="POST" action="{{ url_for('quotes.request_approval', quote_id=quote.id) }}" class="inline">
|
||||
|
||||
+33
-3
@@ -37,11 +37,12 @@ def extract_token_from_request():
|
||||
return None
|
||||
|
||||
|
||||
def authenticate_token(token_string):
|
||||
def authenticate_token(token_string, record_usage: bool = True):
|
||||
"""Authenticate an API token and return the associated user
|
||||
|
||||
Args:
|
||||
token_string: The plain token string
|
||||
record_usage: If True, increment usage counters (commit). Set False when rate limit runs first.
|
||||
|
||||
Returns:
|
||||
tuple: (User, ApiToken, error_message) if invalid, (User, ApiToken, None) if valid
|
||||
@@ -146,8 +147,8 @@ def require_api_token(required_scope=None):
|
||||
401,
|
||||
)
|
||||
|
||||
# Authenticate token
|
||||
user, api_token, error_msg = authenticate_token(token_string)
|
||||
# Authenticate token (defer usage recording until after rate limit)
|
||||
user, api_token, error_msg = authenticate_token(token_string, record_usage=False)
|
||||
|
||||
if not user or not api_token:
|
||||
message = error_msg or "The provided API token is invalid or expired"
|
||||
@@ -180,6 +181,35 @@ def require_api_token(required_scope=None):
|
||||
403,
|
||||
)
|
||||
|
||||
# Per-token rate limit (minute + hour)
|
||||
try:
|
||||
from app.utils.api_rate_limit import consume_api_token_rate_limit
|
||||
|
||||
allowed, rl_info = consume_api_token_rate_limit(api_token.id)
|
||||
if not allowed:
|
||||
retry_after = int(rl_info.get("retry_after_seconds") or 60)
|
||||
resp = jsonify(
|
||||
{
|
||||
"error": "Rate limit exceeded",
|
||||
"message": "Too many requests for this API token. Try again later.",
|
||||
"error_code": "rate_limited",
|
||||
"limit_per_minute": rl_info.get("limit_minute"),
|
||||
"limit_per_hour": rl_info.get("limit_hour"),
|
||||
"remaining_per_minute": rl_info.get("remaining_minute"),
|
||||
"remaining_per_hour": rl_info.get("remaining_hour"),
|
||||
}
|
||||
)
|
||||
resp.status_code = 429
|
||||
resp.headers["Retry-After"] = str(retry_after)
|
||||
return resp
|
||||
except Exception as e:
|
||||
current_app.logger.warning("API token rate limit check failed (allowing request): %s", e)
|
||||
|
||||
try:
|
||||
api_token.record_usage(request.remote_addr)
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f"Failed to record API token usage: {e}")
|
||||
|
||||
# Store in request context
|
||||
g.api_user = user
|
||||
g.api_token = api_token
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Idempotency-Key header handling for API v1 writes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
from flask import Response, jsonify
|
||||
|
||||
from app import db
|
||||
from app.models.api_idempotency_key import ApiIdempotencyKey
|
||||
from app.utils.db import safe_commit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IDEMPOTENCY_TTL_HOURS = 24
|
||||
MAX_KEY_LEN = 128
|
||||
SCOPE_POST_TIME_ENTRY = "post:time_entries"
|
||||
|
||||
|
||||
def _hash_key(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalize_idempotency_key(header_value: Optional[str]) -> Optional[str]:
|
||||
if not header_value or not isinstance(header_value, str):
|
||||
return None
|
||||
s = header_value.strip()
|
||||
if not s or len(s) > MAX_KEY_LEN:
|
||||
return None
|
||||
return s
|
||||
|
||||
|
||||
def lookup_idempotent_response(api_token_id: int, scope: str, key: str) -> Optional[Tuple[int, str]]:
|
||||
row = ApiIdempotencyKey.query.filter_by(
|
||||
api_token_id=api_token_id,
|
||||
scope=scope,
|
||||
key_hash=_hash_key(key),
|
||||
).first()
|
||||
if not row:
|
||||
return None
|
||||
cutoff = datetime.utcnow() - timedelta(hours=IDEMPOTENCY_TTL_HOURS)
|
||||
if row.created_at < cutoff:
|
||||
try:
|
||||
db.session.delete(row)
|
||||
safe_commit("idempotency_expired_cleanup", {})
|
||||
except Exception as e:
|
||||
logger.debug("Idempotency cleanup: %s", e)
|
||||
return None
|
||||
return row.response_status, row.response_body
|
||||
|
||||
|
||||
def store_idempotent_response(api_token_id: int, scope: str, key: str, status_code: int, body_dict: Any) -> None:
|
||||
body_json = json.dumps(body_dict, default=str)
|
||||
row = ApiIdempotencyKey(
|
||||
api_token_id=api_token_id,
|
||||
scope=scope,
|
||||
key_hash=_hash_key(key),
|
||||
response_status=status_code,
|
||||
response_body=body_json,
|
||||
)
|
||||
db.session.add(row)
|
||||
if not safe_commit("api_idempotency_store", {"scope": scope}):
|
||||
logger.warning("Failed to store idempotency key for token %s", api_token_id)
|
||||
|
||||
|
||||
def replay_response(status_code: int, body_json: str) -> Response:
|
||||
try:
|
||||
data = json.loads(body_json)
|
||||
except Exception:
|
||||
data = {"message": body_json}
|
||||
resp = jsonify(data)
|
||||
resp.status_code = status_code
|
||||
return resp
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Per API token rate limiting (minute + hour windows).
|
||||
|
||||
Uses Redis INCR when REDIS_URL is reachable; otherwise a process-local fallback
|
||||
(suitable for single-worker dev; production should set Redis).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LOCAL_LOCK = threading.Lock()
|
||||
_LOCAL_MINUTE: Dict[tuple, tuple] = {} # (token_id, minute_epoch) -> (count, reset_ts)
|
||||
_LOCAL_HOUR: Dict[tuple, tuple] = {} # (token_id, hour_epoch) -> (count, reset_ts)
|
||||
|
||||
|
||||
def _limits_from_config() -> Tuple[int, int]:
|
||||
try:
|
||||
per_min = int(current_app.config.get("API_TOKEN_RATE_LIMIT_PER_MINUTE", 100))
|
||||
per_hour = int(current_app.config.get("API_TOKEN_RATE_LIMIT_PER_HOUR", 1000))
|
||||
except Exception:
|
||||
per_min, per_hour = 100, 1000
|
||||
return max(1, per_min), max(1, per_hour)
|
||||
|
||||
|
||||
def _redis_client():
|
||||
try:
|
||||
import redis
|
||||
from urllib.parse import urlparse
|
||||
|
||||
if not current_app.config.get("REDIS_ENABLED", True):
|
||||
return None
|
||||
url = current_app.config.get("REDIS_URL", "redis://localhost:6379/0")
|
||||
parsed = urlparse(url)
|
||||
password = parsed.password or None
|
||||
client = redis.Redis(
|
||||
host=parsed.hostname or "localhost",
|
||||
port=parsed.port or 6379,
|
||||
password=password,
|
||||
db=int(parsed.path.lstrip("/")) if parsed.path else 0,
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=0.5,
|
||||
socket_timeout=0.5,
|
||||
retry_on_timeout=False,
|
||||
)
|
||||
client.ping()
|
||||
return client
|
||||
except Exception as e:
|
||||
logger.debug("API rate limit Redis unavailable: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def _cleanup_local(now: float) -> None:
|
||||
"""Drop expired local buckets (best-effort)."""
|
||||
with _LOCAL_LOCK:
|
||||
for d in (_LOCAL_MINUTE, _LOCAL_HOUR):
|
||||
dead = [k for k, (_, exp) in d.items() if exp <= now]
|
||||
for k in dead:
|
||||
del d[k]
|
||||
|
||||
|
||||
def consume_api_token_rate_limit(token_id: int) -> Tuple[bool, Dict[str, Any]]:
|
||||
"""
|
||||
Increment counters for this token and return whether the request is allowed.
|
||||
|
||||
Returns:
|
||||
(allowed, info) where info may include limit_minute, limit_hour, remaining_minute,
|
||||
remaining_hour, retry_after_seconds (when not allowed).
|
||||
"""
|
||||
per_min, per_hour = _limits_from_config()
|
||||
now = time.time()
|
||||
minute_epoch = int(now // 60)
|
||||
hour_epoch = int(now // 3600)
|
||||
|
||||
r = _redis_client()
|
||||
if r is not None:
|
||||
try:
|
||||
km = f"tt:api_rl:{token_id}:m:{minute_epoch}"
|
||||
kh = f"tt:api_rl:{token_id}:h:{hour_epoch}"
|
||||
pipe = r.pipeline()
|
||||
pipe.incr(km)
|
||||
pipe.expire(km, 120)
|
||||
pipe.incr(kh)
|
||||
pipe.expire(kh, 7200)
|
||||
c_min, _, c_hour, _ = pipe.execute()
|
||||
c_min = int(c_min)
|
||||
c_hour = int(c_hour)
|
||||
if c_min > per_min:
|
||||
return False, {
|
||||
"limit_minute": per_min,
|
||||
"limit_hour": per_hour,
|
||||
"remaining_minute": 0,
|
||||
"remaining_hour": max(0, per_hour - c_hour),
|
||||
"retry_after_seconds": 60 - int(now % 60) or 60,
|
||||
}
|
||||
if c_hour > per_hour:
|
||||
return False, {
|
||||
"limit_minute": per_min,
|
||||
"limit_hour": per_hour,
|
||||
"remaining_minute": max(0, per_min - c_min),
|
||||
"remaining_hour": 0,
|
||||
"retry_after_seconds": 3600 - int(now % 3600) or 3600,
|
||||
}
|
||||
return True, {
|
||||
"limit_minute": per_min,
|
||||
"limit_hour": per_hour,
|
||||
"remaining_minute": max(0, per_min - c_min),
|
||||
"remaining_hour": max(0, per_hour - c_hour),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Redis rate limit failed, using local fallback: %s", e)
|
||||
|
||||
_cleanup_local(now)
|
||||
with _LOCAL_LOCK:
|
||||
mk = (token_id, minute_epoch)
|
||||
hk = (token_id, hour_epoch)
|
||||
exp_m = (minute_epoch + 1) * 60
|
||||
exp_h = (hour_epoch + 1) * 3600
|
||||
cm, _ = _LOCAL_MINUTE.get(mk, (0, exp_m))
|
||||
ch, _ = _LOCAL_HOUR.get(hk, (0, exp_h))
|
||||
cm += 1
|
||||
ch += 1
|
||||
_LOCAL_MINUTE[mk] = (cm, exp_m)
|
||||
_LOCAL_HOUR[hk] = (ch, exp_h)
|
||||
if cm > per_min:
|
||||
return False, {
|
||||
"limit_minute": per_min,
|
||||
"limit_hour": per_hour,
|
||||
"remaining_minute": 0,
|
||||
"remaining_hour": max(0, per_hour - ch),
|
||||
"retry_after_seconds": 60 - int(now % 60) or 60,
|
||||
}
|
||||
if ch > per_hour:
|
||||
return False, {
|
||||
"limit_minute": per_min,
|
||||
"limit_hour": per_hour,
|
||||
"remaining_minute": max(0, per_min - cm),
|
||||
"remaining_hour": 0,
|
||||
"retry_after_seconds": 3600 - int(now % 3600) or 3600,
|
||||
}
|
||||
return True, {
|
||||
"limit_minute": per_min,
|
||||
"limit_hour": per_hour,
|
||||
"remaining_minute": max(0, per_min - cm),
|
||||
"remaining_hour": max(0, per_hour - ch),
|
||||
}
|
||||
+23
-11
@@ -78,6 +78,14 @@ def _text_el(parent: ET.Element, tag: str, text: Optional[str]) -> Optional[ET.E
|
||||
return el
|
||||
|
||||
|
||||
def _money_el(parent: ET.Element, tag: str, value: Any, currency: str) -> Optional[ET.Element]:
|
||||
"""Monetary element with currencyID (EN 16931 / Factur-X expectation)."""
|
||||
el = _text_el(parent, tag, _money(value))
|
||||
if el is not None:
|
||||
el.set("currencyID", currency)
|
||||
return el
|
||||
|
||||
|
||||
def _date_el(parent: ET.Element, d: Any) -> None:
|
||||
"""Add a DateTimeString child with format 102."""
|
||||
udt = f"{{{NS_UDT}}}"
|
||||
@@ -193,11 +201,14 @@ def build_cii_invoice_xml(
|
||||
|
||||
# Tax summary
|
||||
tax_el = _sub(settlement, ram + "ApplicableTradeTax")
|
||||
calc_amt = _text_el(tax_el, ram + "CalculatedAmount", _money(getattr(invoice, "tax_amount", 0)))
|
||||
_money_el(tax_el, ram + "CalculatedAmount", getattr(invoice, "tax_amount", 0), currency)
|
||||
_text_el(tax_el, ram + "TypeCode", "VAT")
|
||||
basis_amt = _text_el(tax_el, ram + "BasisAmount", _money(getattr(invoice, "subtotal", 0)))
|
||||
_money_el(tax_el, ram + "BasisAmount", getattr(invoice, "subtotal", 0), currency)
|
||||
_text_el(tax_el, ram + "CategoryCode", tax_category)
|
||||
_text_el(tax_el, ram + "RateApplicablePercent", _money(tax_rate))
|
||||
if tax_category == "Z":
|
||||
_text_el(tax_el, ram + "ExemptionReason", "Not subject to VAT")
|
||||
_text_el(tax_el, ram + "ExemptionReasonCode", "VATEX-EU-O")
|
||||
|
||||
# Payment terms (due date)
|
||||
due_date = getattr(invoice, "due_date", None)
|
||||
@@ -208,13 +219,11 @@ def build_cii_invoice_xml(
|
||||
|
||||
# Monetary summation
|
||||
totals = _sub(settlement, ram + "SpecifiedTradeSettlementHeaderMonetarySummation")
|
||||
_text_el(totals, ram + "LineTotalAmount", _money(getattr(invoice, "subtotal", 0)))
|
||||
_text_el(totals, ram + "TaxBasisTotalAmount", _money(getattr(invoice, "subtotal", 0)))
|
||||
tax_total_el = _text_el(totals, ram + "TaxTotalAmount", _money(getattr(invoice, "tax_amount", 0)))
|
||||
if tax_total_el is not None:
|
||||
tax_total_el.set("currencyID", currency)
|
||||
_text_el(totals, ram + "GrandTotalAmount", _money(getattr(invoice, "total_amount", 0)))
|
||||
_text_el(totals, ram + "DuePayableAmount", _money(getattr(invoice, "total_amount", 0)))
|
||||
_money_el(totals, ram + "LineTotalAmount", getattr(invoice, "subtotal", 0), currency)
|
||||
_money_el(totals, ram + "TaxBasisTotalAmount", getattr(invoice, "subtotal", 0), currency)
|
||||
_money_el(totals, ram + "TaxTotalAmount", getattr(invoice, "tax_amount", 0), currency)
|
||||
_money_el(totals, ram + "GrandTotalAmount", getattr(invoice, "total_amount", 0), currency)
|
||||
_money_el(totals, ram + "DuePayableAmount", getattr(invoice, "total_amount", 0), currency)
|
||||
|
||||
# --- Line Items ---
|
||||
line_id = 1
|
||||
@@ -231,7 +240,7 @@ def build_cii_invoice_xml(
|
||||
|
||||
line_agreement = _sub(li, ram + "SpecifiedLineTradeAgreement")
|
||||
net_price = _sub(line_agreement, ram + "NetPriceProductTradePrice")
|
||||
_text_el(net_price, ram + "ChargeAmount", _money(unit_price))
|
||||
_money_el(net_price, ram + "ChargeAmount", unit_price, currency)
|
||||
|
||||
line_delivery = _sub(li, ram + "SpecifiedLineTradeDelivery")
|
||||
qty_el = _text_el(line_delivery, ram + "BilledQuantity", _qty(quantity))
|
||||
@@ -243,9 +252,12 @@ def build_cii_invoice_xml(
|
||||
_text_el(line_tax, ram + "TypeCode", "VAT")
|
||||
_text_el(line_tax, ram + "CategoryCode", tax_category)
|
||||
_text_el(line_tax, ram + "RateApplicablePercent", _money(tax_rate))
|
||||
if tax_category == "Z":
|
||||
_text_el(line_tax, ram + "ExemptionReason", "Not subject to VAT")
|
||||
_text_el(line_tax, ram + "ExemptionReasonCode", "VATEX-EU-O")
|
||||
|
||||
line_totals = _sub(line_settle, ram + "SpecifiedTradeSettlementLineMonetarySummation")
|
||||
_text_el(line_totals, ram + "LineTotalAmount", _money(line_total))
|
||||
_money_el(line_totals, ram + "LineTotalAmount", line_total, currency)
|
||||
|
||||
line_id += 1
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ def safe_query(query_func: Callable[[], T], default: Optional[T] = None) -> Opti
|
||||
try:
|
||||
db.session.rollback()
|
||||
current_app.logger.warning(f"Query failed after rollback retry: {retry_error} (original: {e})")
|
||||
current_app.logger.debug(
|
||||
"safe_query returning default after failed SQLAlchemy retry (%s)",
|
||||
type(retry_error).__name__,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
@@ -48,6 +52,11 @@ def safe_query(query_func: Callable[[], T], default: Optional[T] = None) -> Opti
|
||||
try:
|
||||
db.session.rollback()
|
||||
current_app.logger.warning(f"Unexpected error in safe_query: {e}")
|
||||
current_app.logger.debug(
|
||||
"safe_query returning default after unexpected error (%s)",
|
||||
type(e).__name__,
|
||||
exc_info=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
@@ -639,6 +639,17 @@ def _build_invoice_email_payload(invoice, email_template_id=None, custom_message
|
||||
raise ValueError("PDF generation returned empty result")
|
||||
|
||||
settings = Settings.get_settings()
|
||||
from app.utils.invoice_pdf_postprocess import postprocess_invoice_pdf_bytes
|
||||
|
||||
pdf_bytes, embed_err, pdfa_err = postprocess_invoice_pdf_bytes(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
current_app.logger.error(f"[INVOICE EMAIL] Factur-X embed failed: {embed_err}")
|
||||
raise ValueError(
|
||||
f"Factur-X embedding is enabled but failed: {embed_err}. Email not sent so the PDF does not ship without embedded XML."
|
||||
) from None
|
||||
if pdfa_err:
|
||||
current_app.logger.error(f"[INVOICE EMAIL] PDF/A-3 normalization failed: {pdfa_err}")
|
||||
raise ValueError(f"PDF/A-3 normalization failed: {pdfa_err}. Email not sent.") from None
|
||||
company_name = settings.company_name if settings else "Your Company"
|
||||
subject = f"Invoice {invoice.invoice_number} from {company_name}"
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Shared HTTP session for outbound integration calls (retries, timeouts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = (5, 30)
|
||||
|
||||
|
||||
def integration_session(
|
||||
total_retries: int = 3,
|
||||
backoff_factor: float = 0.5,
|
||||
timeout: tuple = _DEFAULT_TIMEOUT,
|
||||
) -> requests.Session:
|
||||
"""
|
||||
Session with retry on 429, 500, 502, 503, 504 for GET/POST/PUT/PATCH/DELETE.
|
||||
"""
|
||||
session = requests.Session()
|
||||
retry = Retry(
|
||||
total=total_retries,
|
||||
connect=total_retries,
|
||||
read=total_retries,
|
||||
backoff_factor=backoff_factor,
|
||||
status_forcelist=(429, 500, 502, 503, 504),
|
||||
allowed_methods=frozenset(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]),
|
||||
raise_on_status=False,
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=20)
|
||||
session.mount("http://", adapter)
|
||||
session.mount("https://", adapter)
|
||||
session.request_timeout = timeout # type: ignore[attr-defined]
|
||||
return session
|
||||
|
||||
|
||||
def session_request(
|
||||
session: requests.Session,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json: Any = None,
|
||||
data: Any = None,
|
||||
timeout: Optional[tuple] = None,
|
||||
) -> requests.Response:
|
||||
"""Perform request using session's default timeout."""
|
||||
to = timeout or getattr(session, "request_timeout", _DEFAULT_TIMEOUT)
|
||||
return session.request(method.upper(), url, headers=headers, params=params, json=json, data=data, timeout=to)
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Resolve client and actor user for integration sync (especially global integrations).
|
||||
|
||||
Per-user integrations use integration.user_id. Global integrations use, in order:
|
||||
1. INTEGRATION_SYNC_USER_ID (numeric user id)
|
||||
2. First active user with role admin
|
||||
3. First active user
|
||||
|
||||
Projects are created under a dedicated client (default name "Integration imports"),
|
||||
overridable via INTEGRATION_IMPORT_CLIENT_NAME.
|
||||
|
||||
External system linkage is stored in Project.custom_fields / Task.custom_fields under
|
||||
the key "integration": {"source": "<provider>", "ref": "<stable id>"}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_IMPORT_CLIENT_NAME = "Integration imports"
|
||||
|
||||
|
||||
def _import_client_name() -> str:
|
||||
raw = (os.getenv("INTEGRATION_IMPORT_CLIENT_NAME") or "").strip()
|
||||
return raw or DEFAULT_IMPORT_CLIENT_NAME
|
||||
|
||||
|
||||
def get_or_create_integration_import_client():
|
||||
"""Return the shared Client used for imported integration projects; flush only (caller commits)."""
|
||||
from app import db
|
||||
from app.models import Client
|
||||
|
||||
name = _import_client_name()
|
||||
c = Client.query.filter_by(name=name).first()
|
||||
if c:
|
||||
return c
|
||||
c = Client(name=name)
|
||||
db.session.add(c)
|
||||
db.session.flush()
|
||||
return c
|
||||
|
||||
|
||||
def resolve_integration_actor_user_id(integration) -> Optional[int]:
|
||||
"""
|
||||
User id to use for Task.created_by and similar when syncing.
|
||||
"""
|
||||
from app.models import User
|
||||
|
||||
if integration is not None and getattr(integration, "user_id", None) is not None:
|
||||
return integration.user_id
|
||||
|
||||
env_raw = (os.getenv("INTEGRATION_SYNC_USER_ID") or "").strip()
|
||||
if env_raw.isdigit():
|
||||
uid = int(env_raw)
|
||||
from app import db
|
||||
|
||||
u = db.session.get(User, uid)
|
||||
if u and getattr(u, "is_active", True):
|
||||
return uid
|
||||
logger.warning("INTEGRATION_SYNC_USER_ID=%s is missing or inactive", env_raw)
|
||||
|
||||
admin = User.query.filter_by(role="admin", is_active=True).order_by(User.id).first()
|
||||
if admin:
|
||||
return admin.id
|
||||
|
||||
any_user = User.query.filter_by(is_active=True).order_by(User.id).first()
|
||||
return any_user.id if any_user else None
|
||||
|
||||
|
||||
def require_sync_context(integration) -> Tuple[int, int]:
|
||||
"""
|
||||
Returns (actor_user_id, import_client_id).
|
||||
Raises ValueError with a clear message if no actor user exists.
|
||||
"""
|
||||
uid = resolve_integration_actor_user_id(integration)
|
||||
if uid is None:
|
||||
raise ValueError(
|
||||
"No active user found to attribute imported tasks to. "
|
||||
"Create a user or set INTEGRATION_SYNC_USER_ID to a valid user id."
|
||||
)
|
||||
client = get_or_create_integration_import_client()
|
||||
return uid, client.id
|
||||
|
||||
|
||||
def find_project_by_integration_ref(client_id: int, source: str, ref: str):
|
||||
from app.models import Project
|
||||
|
||||
for p in Project.query.filter_by(client_id=client_id).all():
|
||||
cf = p.custom_fields if p.custom_fields is not None else {}
|
||||
block = cf.get("integration") if isinstance(cf, dict) else {}
|
||||
if isinstance(block, dict) and block.get("source") == source and block.get("ref") == ref:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def ensure_project_integration_fields(
|
||||
project,
|
||||
*,
|
||||
source: str,
|
||||
ref: str,
|
||||
display_name: str,
|
||||
description: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Attach integration marker to project custom_fields (mutates in place)."""
|
||||
cf: Dict[str, Any] = dict(project.custom_fields) if isinstance(project.custom_fields, dict) else {}
|
||||
cf["integration"] = {"source": source, "ref": ref}
|
||||
project.custom_fields = cf
|
||||
if display_name and project.name != display_name:
|
||||
project.name = display_name
|
||||
if description is not None:
|
||||
project.description = description
|
||||
|
||||
|
||||
def find_task_by_integration_ref(project_id: int, ref: str, source: Optional[str] = None):
|
||||
"""Match task by integration ref. If ``source`` is set, require the same integration source."""
|
||||
from app.models import Task
|
||||
|
||||
for t in Task.query.filter_by(project_id=project_id).all():
|
||||
cf = t.custom_fields if t.custom_fields is not None else {}
|
||||
block = cf.get("integration") if isinstance(cf, dict) else {}
|
||||
if not isinstance(block, dict) or block.get("ref") != ref:
|
||||
continue
|
||||
if source is not None and block.get("source") != source:
|
||||
continue
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def set_task_integration_ref(task, *, source: str, ref: str, extra: Optional[Dict[str, Any]] = None) -> None:
|
||||
cf: Dict[str, Any] = dict(task.custom_fields) if isinstance(task.custom_fields, dict) else {}
|
||||
block: Dict[str, Any] = {"source": source, "ref": ref}
|
||||
if extra:
|
||||
block.update(extra)
|
||||
cf["integration"] = block
|
||||
task.custom_fields = cf
|
||||
|
||||
|
||||
def sync_result_item_count(sync_result: Optional[Dict[str, Any]]) -> int:
|
||||
"""Normalize synced_count vs synced_items from connector sync_data return values."""
|
||||
if not sync_result or not isinstance(sync_result, dict):
|
||||
return 0
|
||||
for key in ("synced_count", "synced_items"):
|
||||
v = sync_result.get(key)
|
||||
if v is not None:
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return 0
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Shared Factur-X (ZUGFeRD) embed + PDF/A-3 post-processing for invoice PDFs.
|
||||
|
||||
Used by HTTP PDF export and email attachment generation so behavior matches.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
|
||||
def postprocess_invoice_pdf_bytes(
|
||||
pdf_bytes: bytes,
|
||||
invoice: Any,
|
||||
settings: Any,
|
||||
) -> Tuple[bytes, Optional[str], Optional[str]]:
|
||||
"""
|
||||
Apply Factur-X CII embedding and optional PDF/A-3 normalization per settings.
|
||||
|
||||
Order: embed Factur-X XML first, then PDF/A-3 (metadata, ICC, optional Ghostscript).
|
||||
|
||||
Returns:
|
||||
(pdf_bytes, embed_error, pdfa_error)
|
||||
- If Factur-X is disabled: returns (pdf_bytes, None, None).
|
||||
- On embed failure: returns (original pdf_bytes, error_message, None).
|
||||
- On PDF/A failure after successful embed: returns (pdf after embed, None, error_message).
|
||||
"""
|
||||
if not getattr(settings, "invoices_zugferd_pdf", False):
|
||||
return pdf_bytes, None, None
|
||||
|
||||
from app.utils.zugferd import embed_zugferd_xml_in_pdf
|
||||
|
||||
out_pdf, embed_err = embed_zugferd_xml_in_pdf(pdf_bytes, invoice, settings)
|
||||
if embed_err:
|
||||
return out_pdf, embed_err, None
|
||||
|
||||
if not getattr(settings, "invoices_pdfa3_compliant", False):
|
||||
return out_pdf, None, None
|
||||
|
||||
from app.utils.pdfa3 import convert_to_pdfa3
|
||||
|
||||
out_pdf, pdfa_err = convert_to_pdfa3(out_pdf)
|
||||
return out_pdf, None, pdfa_err
|
||||
@@ -226,6 +226,11 @@ def validate_cii_en16931(cii_xml: str) -> Tuple[bool, List[str]]:
|
||||
seller_name = seller.find("ram:Name", ns)
|
||||
if seller_name is None or not (seller_name.text or "").strip():
|
||||
issues.append("SellerTradeParty missing Name")
|
||||
seller_addr = seller.find("ram:PostalTradeAddress", ns)
|
||||
if seller_addr is not None:
|
||||
sc = seller_addr.find("ram:CountryID", ns)
|
||||
if sc is None or not (sc.text or "").strip():
|
||||
issues.append("Seller postal address present but CountryID (BT-55) is missing")
|
||||
|
||||
buyer = agreement.find("ram:BuyerTradeParty", ns)
|
||||
if buyer is None:
|
||||
@@ -234,6 +239,11 @@ def validate_cii_en16931(cii_xml: str) -> Tuple[bool, List[str]]:
|
||||
buyer_name = buyer.find("ram:Name", ns)
|
||||
if buyer_name is None or not (buyer_name.text or "").strip():
|
||||
issues.append("BuyerTradeParty missing Name")
|
||||
buyer_addr = buyer.find("ram:PostalTradeAddress", ns)
|
||||
if buyer_addr is not None:
|
||||
bc = buyer_addr.find("ram:CountryID", ns)
|
||||
if bc is None or not (bc.text or "").strip():
|
||||
issues.append("Buyer postal address present but CountryID is missing")
|
||||
|
||||
# Settlement
|
||||
settlement = txn.find("ram:ApplicableHeaderTradeSettlement", ns)
|
||||
@@ -246,10 +256,25 @@ def validate_cii_en16931(cii_xml: str) -> Tuple[bool, List[str]]:
|
||||
if summation is None:
|
||||
issues.append("Missing SpecifiedTradeSettlementHeaderMonetarySummation")
|
||||
else:
|
||||
if summation.find("ram:GrandTotalAmount", ns) is None:
|
||||
gta = summation.find("ram:GrandTotalAmount", ns)
|
||||
if gta is None:
|
||||
issues.append("Missing GrandTotalAmount")
|
||||
elif not (gta.get("currencyID") or "").strip():
|
||||
issues.append("GrandTotalAmount missing currencyID attribute")
|
||||
if summation.find("ram:DuePayableAmount", ns) is None:
|
||||
issues.append("Missing DuePayableAmount")
|
||||
else:
|
||||
dpa = summation.find("ram:DuePayableAmount", ns)
|
||||
if dpa is not None and not (dpa.get("currencyID") or "").strip():
|
||||
issues.append("DuePayableAmount missing currencyID attribute")
|
||||
|
||||
header_tax = settlement.find("ram:ApplicableTradeTax", ns)
|
||||
if header_tax is not None:
|
||||
cat = header_tax.find("ram:CategoryCode", ns)
|
||||
cat_txt = (cat.text or "").strip() if cat is not None else ""
|
||||
if cat_txt == "Z":
|
||||
if header_tax.find("ram:ExemptionReason", ns) is None:
|
||||
issues.append("CategoryCode Z requires ExemptionReason (BT-120)")
|
||||
|
||||
# Line items
|
||||
lines = txn.findall("ram:IncludedSupplyChainTradeLineItem", ns)
|
||||
|
||||
@@ -402,6 +402,24 @@ class InvoicePDFGeneratorFallback:
|
||||
return story
|
||||
|
||||
|
||||
def format_quote_item_description_for_pdf(item) -> str:
|
||||
"""Label for a quote line including expense/goods metadata (issue #585)."""
|
||||
dn = getattr(item, "display_name", None)
|
||||
desc = (getattr(item, "description", None) or "") or ""
|
||||
lk = (getattr(item, "line_kind", None) or "item") or "item"
|
||||
if dn:
|
||||
text = str(dn)
|
||||
if desc and str(desc) not in (str(dn), "-"):
|
||||
text = f"{text} — {desc}"
|
||||
else:
|
||||
text = str(desc)
|
||||
if lk == "expense" and getattr(item, "category", None):
|
||||
text = f"{text} ({item.category})"
|
||||
if lk == "good" and getattr(item, "sku", None):
|
||||
text = f"{text} [SKU: {item.sku}]"
|
||||
return text
|
||||
|
||||
|
||||
class QuotePDFGeneratorFallback:
|
||||
"""Generate PDF quotes with company branding using ReportLab"""
|
||||
|
||||
@@ -559,7 +577,7 @@ class QuotePDFGeneratorFallback:
|
||||
for item in self.quote.items:
|
||||
data.append(
|
||||
[
|
||||
item.description,
|
||||
format_quote_item_description_for_pdf(item),
|
||||
str(item.quantity),
|
||||
self._format_currency(item.unit_price),
|
||||
self._format_currency(item.total_amount),
|
||||
|
||||
+26
-2
@@ -15,10 +15,14 @@ Limitations:
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import struct
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
# Bundled sRGB profile (Compact ICC, MIT license — see app/resources/icc/LICENSE if present)
|
||||
_BUNDLED_SRGB_ICC = Path(__file__).resolve().parent.parent / "resources" / "icc" / "sRGB-v2-nano.icc"
|
||||
|
||||
PDFA_PART = "3"
|
||||
PDFA_CONFORMANCE = "B"
|
||||
PDFA_NS = "http://www.aiim.org/pdfa/ns/id/"
|
||||
@@ -29,6 +33,26 @@ OUTPUT_INTENT_REGISTRY = "http://www.color.org"
|
||||
OUTPUT_INTENT_INFO = "sRGB IEC61966-2.1"
|
||||
|
||||
|
||||
def _srgb_icc_profile_bytes() -> bytes:
|
||||
"""
|
||||
Prefer INVOICE_SRGB_ICC_PATH if set, then bundled nano sRGB ICC, else synthetic minimal profile.
|
||||
"""
|
||||
env_path = (os.environ.get("INVOICE_SRGB_ICC_PATH") or "").strip()
|
||||
if env_path:
|
||||
try:
|
||||
p = Path(env_path)
|
||||
if p.is_file():
|
||||
return p.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if _BUNDLED_SRGB_ICC.is_file():
|
||||
return _BUNDLED_SRGB_ICC.read_bytes()
|
||||
except OSError:
|
||||
pass
|
||||
return _minimal_srgb_icc_profile()
|
||||
|
||||
|
||||
def _minimal_srgb_icc_profile() -> bytes:
|
||||
"""
|
||||
Build a minimal sRGB ICC profile that satisfies the PDF/A-3 requirement
|
||||
@@ -205,7 +229,7 @@ def convert_to_pdfa3(pdf_bytes: bytes) -> Tuple[bytes, Optional[str]]:
|
||||
try:
|
||||
from pikepdf import Array, Dictionary, Name, Stream
|
||||
|
||||
icc_data = _minimal_srgb_icc_profile()
|
||||
icc_data = _srgb_icc_profile_bytes()
|
||||
icc_stream = Stream(pdf, icc_data)
|
||||
icc_stream[Name.N] = 3 # RGB = 3 components
|
||||
|
||||
|
||||
@@ -899,7 +899,16 @@ def sync_integrations():
|
||||
f"Failed to sync integration {integration.id} ({integration.provider}): {result.get('message')}"
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
from app.utils.integration_sync_context import sync_result_item_count
|
||||
|
||||
_n = sync_result_item_count(result)
|
||||
service._log_event(
|
||||
integration.id,
|
||||
"sync",
|
||||
bool(result.get("success")),
|
||||
result.get("message"),
|
||||
({"synced_count": _n, "synced_items": _n, "trigger": "scheduler"} if _n or result.get("success") else {"trigger": "scheduler"}),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error syncing integration {integration.id} ({integration.provider}): {str(e)}"
|
||||
@@ -907,7 +916,11 @@ def sync_integrations():
|
||||
logger.error(error_msg, exc_info=True)
|
||||
integration.last_sync_status = "error"
|
||||
integration.last_error = str(e)
|
||||
db.session.commit()
|
||||
try:
|
||||
service._log_event(integration.id, "sync", False, str(e), {"trigger": "scheduler"})
|
||||
except Exception as log_err:
|
||||
logger.warning("Could not log integration sync failure: %s", log_err)
|
||||
db.session.commit()
|
||||
|
||||
logger.info(f"Integration sync completed. Synced {synced_count}/{len(active_integrations)} integrations")
|
||||
if errors:
|
||||
|
||||
+10
-7
@@ -9,8 +9,9 @@ Standards compliance:
|
||||
- The embedded XML uses UN/CEFACT CII format (NOT UBL). This is the
|
||||
correct payload format for Factur-X 1.0 / ZUGFeRD 2.x.
|
||||
- Peppol transport uses UBL (see app/integrations/peppol.py).
|
||||
- The file is attached as an Associated File with relationship "Alternative"
|
||||
and Factur-X XMP metadata is written so validators recognize the document.
|
||||
- The file is attached as an Associated File with relationship "Data"
|
||||
(primary machine-readable invoice) and Factur-X XMP metadata is written so
|
||||
validators recognize the document.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -69,6 +70,8 @@ def _get_buyer_party(invoice: Any) -> CIIParty:
|
||||
endpoint_id = (client.get_custom_field("peppol_endpoint_id", "") or "").strip() or None
|
||||
scheme_id = (client.get_custom_field("peppol_scheme_id", "") or "").strip() or None
|
||||
country = (client.get_custom_field("peppol_country", "") or "").strip() or None
|
||||
if not country:
|
||||
country = (client.get_custom_field("country", "") or client.get_custom_field("country_code", "") or "").strip() or None
|
||||
name = (getattr(client, "name", None) or getattr(invoice, "client_name", "") or "Customer").strip()
|
||||
tax_id = (client.get_custom_field("vat_id", "") or client.get_custom_field("tax_id", "") or "").strip() or None
|
||||
address_line = (
|
||||
@@ -159,7 +162,7 @@ def embed_zugferd_xml_in_pdf(pdf_bytes: bytes, invoice: Any, settings: Any) -> T
|
||||
Embed Factur-X CII XML into the given invoice PDF bytes.
|
||||
|
||||
Builds seller/buyer from settings and invoice (best-effort), generates CII
|
||||
XML, attaches it as factur-x.xml with AF relationship "Alternative", adds
|
||||
XML, attaches it as factur-x.xml with AF relationship "Data", adds
|
||||
Factur-X XMP RDF, and returns the new PDF bytes.
|
||||
|
||||
Returns:
|
||||
@@ -184,15 +187,15 @@ def embed_zugferd_xml_in_pdf(pdf_bytes: bytes, invoice: Any, settings: Any) -> T
|
||||
try:
|
||||
from pikepdf import Name
|
||||
|
||||
relationship = Name("/Alternative")
|
||||
relationship = Name("/Data")
|
||||
except ImportError:
|
||||
relationship = "/Alternative"
|
||||
relationship = "/Data"
|
||||
try:
|
||||
filespec = AttachedFileSpec(
|
||||
pdf,
|
||||
cii_bytes,
|
||||
filename=FACTURX_EMBEDDED_FILENAME,
|
||||
mime_type="application/xml",
|
||||
mime_type="text/xml",
|
||||
relationship=relationship,
|
||||
)
|
||||
except TypeError:
|
||||
@@ -200,7 +203,7 @@ def embed_zugferd_xml_in_pdf(pdf_bytes: bytes, invoice: Any, settings: Any) -> T
|
||||
tmp.write(cii_bytes)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
filespec = AttachedFileSpec.from_filepath(pdf, tmp_path, relationship="/Alternative")
|
||||
filespec = AttachedFileSpec.from_filepath(pdf, tmp_path, relationship="/Data")
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# TimeTracker - Code-Based Analysis Report
|
||||
|
||||
**Date:** 2025-01-27
|
||||
**Date:** 2026-04-05
|
||||
**Analysis Method:** Direct code examination (routes, models, services, integrations)
|
||||
**Version:** 4.8.8
|
||||
**Version:** 5.3.0
|
||||
|
||||
---
|
||||
|
||||
@@ -12,9 +12,9 @@ This report provides a **code-based analysis** of the TimeTracker project by exa
|
||||
|
||||
- **63 route files** with **1,826+ route definitions**
|
||||
- **83+ model files** defining data structures
|
||||
- **39 service files** implementing business logic
|
||||
- **12 integration connectors** for external services
|
||||
- **Complete API v1** with comprehensive endpoints
|
||||
- **40+ service files** implementing business logic
|
||||
- **14 integration connectors** for external services
|
||||
- **Complete API v1** with comprehensive endpoints (including CSV import, bulk time-entry actions, optional per-token rate limits, and idempotent `POST /api/v1/time-entries` via `Idempotency-Key`)
|
||||
|
||||
**Key Findings:**
|
||||
- ✅ **Most features ARE fully implemented** - Previous analysis underestimated completeness
|
||||
@@ -327,7 +327,7 @@ This report provides a **code-based analysis** of the TimeTracker project by exa
|
||||
38. `base_crud_service.py` - Base CRUD operations
|
||||
39. `project_template_service.py` - Project template operations
|
||||
|
||||
**Conclusion:** Comprehensive service layer with 39 services covering all major features.
|
||||
**Conclusion:** Comprehensive service layer with 40+ services covering all major features (including dedicated modules for API time-entry bulk actions and CSV import).
|
||||
|
||||
---
|
||||
|
||||
@@ -335,20 +335,22 @@ This report provides a **code-based analysis** of the TimeTracker project by exa
|
||||
|
||||
### Integration Connectors
|
||||
|
||||
**Total Integrations:** 12
|
||||
**Total Integrations:** 14
|
||||
|
||||
1. **Jira** (`jira.py`) - Project/task sync
|
||||
2. **Slack** (`slack.py`) - Notifications
|
||||
3. **GitHub** (`github.py`) - Issue sync
|
||||
4. **GitLab** (`gitlab.py`) - Issue sync
|
||||
2. **Linear** (`linear.py`) - Issue import as tasks (API key)
|
||||
3. **Slack** (`slack.py`) - Notifications
|
||||
4. **GitHub** (`github.py`) - Issue sync
|
||||
5. **Google Calendar** (`google_calendar.py`) - Two-way calendar sync
|
||||
6. **Outlook Calendar** (`outlook_calendar.py`) - Two-way calendar sync
|
||||
7. **CalDAV Calendar** (`caldav_calendar.py`) - Calendar sync (one-way currently)
|
||||
8. **Microsoft Teams** (`microsoft_teams.py`) - Notifications
|
||||
9. **Asana** (`asana.py`) - Project/task sync
|
||||
10. **Trello** (`trello.py`) - Board/card sync
|
||||
11. **QuickBooks** (`quickbooks.py`) - Invoice/expense sync
|
||||
12. **Xero** (`xero.py`) - Invoice/expense sync
|
||||
8. **ActivityWatch** (`activitywatch.py`) - Automatic time entries from local aw-server
|
||||
9. **Microsoft Teams** (`microsoft_teams.py`) - Notifications
|
||||
10. **Asana** (`asana.py`) - Project/task sync
|
||||
11. **Trello** (`trello.py`) - Board/card sync
|
||||
12. **GitLab** (`gitlab.py`) - Issue sync
|
||||
13. **QuickBooks** (`quickbooks.py`) - Invoice/expense sync
|
||||
14. **Xero** (`xero.py`) - Invoice/expense sync
|
||||
|
||||
### Integration Features
|
||||
|
||||
@@ -359,8 +361,8 @@ All integrations implement:
|
||||
- Webhook handling (where applicable)
|
||||
|
||||
**Issues Found:**
|
||||
- ⚠️ GitHub webhook signature verification has placeholder (`pass` statement)
|
||||
- ⚠️ CalDAV bidirectional sync not fully implemented (one-way only)
|
||||
- GitHub webhooks: `handle_webhook` verifies `X-Hub-Signature-256` with HMAC-SHA256 when `webhook_secret` is set; requests without a valid signature are rejected (configure the same secret in GitHub and in integration config).
|
||||
- CalDAV: connector supports bidirectional mode in code (`sync_direction` / `bidirectional`); operational complexity and server differences may still require validation per environment.
|
||||
- ⚠️ QuickBooks customer/account mapping uses hardcoded values
|
||||
|
||||
---
|
||||
@@ -373,7 +375,7 @@ All integrations implement:
|
||||
|
||||
#### Core Endpoints
|
||||
- `/api/v1/projects` - Full CRUD
|
||||
- `/api/v1/time-entries` - Full CRUD
|
||||
- `/api/v1/time-entries` - Full CRUD, CSV import (`POST .../import-csv`), bulk actions (`POST .../bulk`), idempotent create (`Idempotency-Key` on `POST .../time-entries`)
|
||||
- `/api/v1/tasks` - Full CRUD
|
||||
- `/api/v1/clients` - Full CRUD
|
||||
- `/api/v1/invoices` - Full CRUD
|
||||
@@ -460,7 +462,7 @@ Based on code examination, no major features are missing. All documented feature
|
||||
|
||||
### Strengths
|
||||
|
||||
1. **Service Layer Architecture** - Well-structured service layer with 39 services
|
||||
1. **Service Layer Architecture** - Well-structured service layer with 40+ services
|
||||
2. **Repository Pattern** - Data access abstraction
|
||||
3. **Comprehensive Models** - 83+ models covering all features
|
||||
4. **API Design** - RESTful API with 308+ endpoints
|
||||
@@ -475,8 +477,8 @@ Based on code examination, no major features are missing. All documented feature
|
||||
- **Note:** Many may be intentional placeholders
|
||||
- **Impact:** Low to medium (error handling may not be comprehensive)
|
||||
|
||||
2. **Webhook Security** - GitHub webhook signature verification incomplete
|
||||
- **Impact:** Medium (security concern)
|
||||
2. **Webhook Security** - Ensure GitHub (and other) webhook endpoints use shared secrets and signature verification; reject unsigned payloads in production.
|
||||
- **Impact:** Medium if misconfigured
|
||||
|
||||
3. **Integration Completeness** - Some integrations need bidirectional sync
|
||||
- **Impact:** Low to medium (feature completeness)
|
||||
@@ -552,9 +554,9 @@ The TimeTracker codebase is **highly complete** with **140+ features** fully imp
|
||||
- ✅ **Inventory management is fully functional** (contrary to previous analysis)
|
||||
- ✅ **Search API exists and works**
|
||||
- ✅ **Issues permission filtering is implemented**
|
||||
- ✅ **Comprehensive service layer** (39 services)
|
||||
- ✅ **Comprehensive service layer** (40+ services)
|
||||
- ✅ **Complete API** (308+ endpoints)
|
||||
- ✅ **12 integrations** with consistent architecture
|
||||
- ✅ **14 integrations** with consistent architecture
|
||||
|
||||
**Remaining Work:**
|
||||
- ⚠️ Minor security enhancements (webhook signature verification)
|
||||
@@ -566,6 +568,6 @@ The TimeTracker codebase is **highly complete** with **140+ features** fully imp
|
||||
|
||||
---
|
||||
|
||||
**Report Generated:** 2025-01-27
|
||||
**Report Generated:** 2026-04-05
|
||||
**Analysis Method:** Direct code examination
|
||||
**Files Analyzed:** 63 route files, 83+ model files, 39 service files, 12 integration files
|
||||
**Files Analyzed:** 63 route files, 83+ model files, 40+ service files, 14 integration connector modules
|
||||
|
||||
@@ -111,8 +111,8 @@ When the setting is on **and** the client has Peppol endpoint details, the invoi
|
||||
|
||||
In **Admin → Settings → Peppol e-Invoicing** you can enable **Embed Factur-X / ZUGFeRD CII XML in invoice PDFs (EN 16931)**. When this is on:
|
||||
|
||||
- **Exported invoice PDFs** (Export PDF) contain an embedded file `factur-x.xml` with a CII (Cross-Industry Invoice) XML conforming to the Factur-X EN 16931 profile.
|
||||
- The embedded XML is attached as an **Associated File** with relationship **Alternative**, and Factur-X XMP metadata is written so validators recognize the document.
|
||||
- **Exported invoice PDFs** (Export PDF) and **invoice emails** (PDF attachment) use the same pipeline: when these settings are on, the attachment contains an embedded file `factur-x.xml` with a CII (Cross-Industry Invoice) XML conforming to the Factur-X EN 16931 profile.
|
||||
- The embedded XML is attached as an **Associated File** with relationship **Data** (primary structured invoice), MIME type **text/xml**, and Factur-X XMP metadata is written so validators recognize the document.
|
||||
- The PDF remains human-readable; the embedded XML makes it machine-readable (e.g. for automated booking or archiving).
|
||||
- **Strict behaviour:** If embedding is enabled and the embed step fails (e.g. missing pikepdf, invalid PDF), the export is **aborted** and the user sees an error; the PDF is not returned without the XML.
|
||||
|
||||
@@ -122,13 +122,15 @@ Party data (seller/buyer) is taken from Settings and the invoice's client (inclu
|
||||
|
||||
### Factur-X and PDF/A-3
|
||||
|
||||
You can enable **Normalize Factur-X PDFs to PDF/A-3b** in Admin → Peppol e-Invoicing. When this is on (and Factur-X embedding is enabled), exported PDFs are normalized to PDF/A-3b:
|
||||
You can enable **Normalize Factur-X PDFs to PDF/A-3b** in Admin → Peppol e-Invoicing. When this is on (and Factur-X embedding is enabled), exported and emailed PDFs are normalized to PDF/A-3b:
|
||||
|
||||
- XMP identification (`pdfaid:part=3`, `pdfaid:conformance=B`)
|
||||
- Embedded sRGB ICC color profile (DestOutputProfile)
|
||||
- Embedded sRGB ICC color profile (`DestOutputProfile`) using a bundled compact sRGB profile under `app/resources/icc/`, or override with environment variable **`INVOICE_SRGB_ICC_PATH`** pointing to a full `.icc` file on the server
|
||||
- GTS_PDFA1 output intent
|
||||
|
||||
If conversion fails, export is aborted and the user sees an error.
|
||||
If conversion fails, export (or sending the invoice email) is aborted and the user sees an error.
|
||||
|
||||
**veraPDF and fonts:** ReportLab invoice templates often use standard fonts without full PDF/A font embedding; veraPDF may still report failures until templates embed fonts or you use an external PDF/A conversion pipeline. **Ghostscript** and similar tools can help but may strip embedded XML if run after Factur-X embedding; prefer tools that preserve associated files, or re-embed `factur-x.xml` after conversion.
|
||||
|
||||
### UBL validation
|
||||
|
||||
@@ -158,5 +160,5 @@ This applies (among others):
|
||||
With your virtual environment activated:
|
||||
|
||||
```bash
|
||||
pytest tests/test_peppol_service.py tests/test_peppol_identifiers.py tests/test_zugferd.py tests/test_pdfa3.py tests/test_invoice_validators.py -v
|
||||
pytest tests/test_peppol_service.py tests/test_peppol_identifiers.py tests/test_zugferd.py tests/test_pdfa3.py tests/test_invoice_pdf_postprocess.py tests/test_invoice_validators.py -v
|
||||
```
|
||||
|
||||
@@ -94,7 +94,9 @@ curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
#### `write:time_entries`
|
||||
**Grants**: Create, update, and delete time entries; control timer; timesheet periods and time-off requests
|
||||
**Endpoints**:
|
||||
- `POST /api/v1/time-entries` - Create time entry
|
||||
- `POST /api/v1/time-entries` - Create time entry (optional `Idempotency-Key` header for safe retries)
|
||||
- `POST /api/v1/time-entries/import-csv` - Import time entries from CSV (multipart `file` or JSON/raw body)
|
||||
- `POST /api/v1/time-entries/bulk` - Bulk delete, billable/paid flags, or tag add/remove
|
||||
- `PUT /api/v1/time-entries/{id}` - Update time entry
|
||||
- `DELETE /api/v1/time-entries/{id}` - Delete time entry
|
||||
- `POST /api/v1/timer/start` - Start timer
|
||||
|
||||
@@ -53,6 +53,19 @@ API tokens follow the format: `tt_<32_random_characters>`
|
||||
|
||||
Example: `tt_abc123def456ghi789jkl012mno345pq`
|
||||
|
||||
### Rate limiting
|
||||
|
||||
Authenticated API requests are counted **per API token** using sliding minute and hour windows. Defaults are **100 requests/minute** and **1000/hour** unless overridden in configuration.
|
||||
|
||||
- **`API_TOKEN_RATE_LIMIT_PER_MINUTE`** — max requests per token per minute (default `100`).
|
||||
- **`API_TOKEN_RATE_LIMIT_PER_HOUR`** — max requests per token per hour (default `1000`).
|
||||
|
||||
When [Redis](https://redis.io/) is available (`REDIS_URL` and Redis enabled in app config), limits are shared across all app workers. Otherwise a process-local fallback is used (fine for single-worker development; use Redis in production with multiple workers).
|
||||
|
||||
### Idempotent time entry creation
|
||||
|
||||
For safe retries (mobile offline sync, webhooks, automation), send a unique **`Idempotency-Key`** header (max 128 characters) on **`POST /api/v1/time-entries`**. The server stores the response for that key for **24 hours** (per token). Repeating the same key returns the **same JSON body and HTTP status** without creating a duplicate entry.
|
||||
|
||||
## Scopes
|
||||
|
||||
API tokens use scopes to control access to resources. When creating a token, select the appropriate scopes:
|
||||
@@ -386,6 +399,48 @@ POST /api/v1/time-entries
|
||||
|
||||
**Note:** `end_time` is optional. Omit it to create an active timer.
|
||||
|
||||
Optional header: **`Idempotency-Key`** — see [Idempotent time entry creation](#idempotent-time-entry-creation) above.
|
||||
|
||||
#### Import time entries (CSV)
|
||||
|
||||
```
|
||||
POST /api/v1/time-entries/import-csv
|
||||
```
|
||||
|
||||
**Required Scope:** `write:time_entries`
|
||||
|
||||
Accepts a CSV file (same column expectations as the web Import/Export flow) either as:
|
||||
|
||||
- **Multipart form**: field name `file`, or
|
||||
- **JSON body**: `{ "csv": "..." }` or `{ "data": "..." }`, or
|
||||
- **Raw body**: CSV text with `Content-Type: text/csv` (or similar).
|
||||
|
||||
Returns a JSON summary (counts, errors) and an appropriate HTTP status.
|
||||
|
||||
#### Bulk actions on time entries
|
||||
|
||||
```
|
||||
POST /api/v1/time-entries/bulk
|
||||
```
|
||||
|
||||
**Required Scope:** `write:time_entries`
|
||||
|
||||
**Request body (JSON):**
|
||||
|
||||
```json
|
||||
{
|
||||
"entry_ids": [1, 2, 3],
|
||||
"action": "delete",
|
||||
"value": null
|
||||
}
|
||||
```
|
||||
|
||||
**`action`** (required): one of `delete`, `set_billable`, `set_paid`, `add_tag`, `remove_tag`.
|
||||
**`value`**: required for tag actions (string tag); for `set_billable` / `set_paid`, pass a boolean.
|
||||
Active (running) entries are skipped for non-delete actions; delete skips active entries.
|
||||
|
||||
Same access rules as the web UI: non-admins may only affect their own entries.
|
||||
|
||||
#### Update Time Entry
|
||||
```
|
||||
PUT /api/v1/time-entries/{entry_id}
|
||||
|
||||
@@ -159,8 +159,12 @@ This document outlines the complete implementation plan for adding a comprehensi
|
||||
- Add `stock_item_id` (Integer, ForeignKey -> stock_items.id, Optional, Indexed)
|
||||
- Add `warehouse_id` (Integer, ForeignKey -> warehouses.id, Optional) - Preferred warehouse
|
||||
- Add `is_stock_item` (Boolean, Default: False) - Flag to indicate if linked to inventory
|
||||
- Add `line_kind` (String(20), not null, default `item`) — discriminates **item**, **expense** (costs), and **good** (extra goods) on a single `quote_items` table (see migration `147_add_quote_item_line_kind.py`)
|
||||
- Optional metadata for non-item lines (nullable): `display_name` (expense title / good name), `category`, `line_date` (expense date), `sku` (good SKU)
|
||||
|
||||
**Behavior**:
|
||||
- Quote create/edit mirrors invoice billing: **line items** (manual or from stock), **costs** (expenses), and **extra goods**. Stock item and warehouse selectors appear only for **item** lines that are explicitly linked to inventory—not on every row.
|
||||
- Inventory fields apply only when `line_kind == "item"` and a stock line is chosen; `expense` and `good` rows clear `stock_item_id` / `warehouse_id`.
|
||||
- When quote item is linked to stock item, show current available quantity
|
||||
- Allow reserving stock when quote is sent (optional)
|
||||
- Auto-reserve on quote acceptance (if enabled)
|
||||
@@ -518,6 +522,9 @@ inventory_permissions = [
|
||||
2. `invoice_items` - Add `stock_item_id`, `warehouse_id`, `is_stock_item`
|
||||
3. `extra_goods` - Add `stock_item_id`
|
||||
|
||||
**Follow-up (quote line kinds, issue #585)** — migration `147_add_quote_item_line_kind.py`:
|
||||
- `quote_items`: `line_kind`, `display_name`, `category`, `line_date`, `sku`
|
||||
|
||||
**Indexes**:
|
||||
- Index on `stock_items.sku`
|
||||
- Index on `stock_items.barcode`
|
||||
@@ -555,6 +562,8 @@ inventory_permissions = [
|
||||
- Color coding for low stock
|
||||
|
||||
### 8.4 Add Stock Item to Quote/Invoice
|
||||
- **Quotes:** Use the line-items section; choose **from stock** on a row to show the product selector and warehouse. Costs and extra goods sections do not offer stock linkage.
|
||||
- **Invoices:** Existing time/stock/expense/goods split remains the reference UX.
|
||||
- Product selector with search/filter
|
||||
- Show available quantity per warehouse
|
||||
- Select warehouse for reservation
|
||||
|
||||
@@ -128,7 +128,7 @@ def import_new_source():
|
||||
|
||||
### API Usage Examples
|
||||
|
||||
**Import CSV via API:**
|
||||
**Import CSV via session (web UI session cookie):**
|
||||
```python
|
||||
import requests
|
||||
|
||||
@@ -141,6 +141,22 @@ response = requests.post(
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
**Import CSV via API v1 (API token):**
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
files = {'file': open('time_entries.csv', 'rb')}
|
||||
response = requests.post(
|
||||
'https://your-domain.com/api/v1/time-entries/import-csv',
|
||||
files=files,
|
||||
headers={'Authorization': 'Bearer YOUR_API_TOKEN'},
|
||||
)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
Requires a token with scope **`write:time_entries`**. See [REST API documentation](../api/REST_API.md#import-time-entries-csv) for alternate bodies (JSON `csv` / `data` or raw CSV).
|
||||
|
||||
**Export GDPR Data:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Linear integration
|
||||
|
||||
TimeTracker can import [Linear](https://linear.app/) issues as **tasks** using a **Personal API key** and the public GraphQL API ([Linear API docs](https://developers.linear.app/docs/graphql/working-with-the-graphql-api)).
|
||||
|
||||
## Authentication
|
||||
|
||||
Linear is **not** OAuth in this connector: you create a **Personal API Key** in Linear (Settings → API) and paste it into TimeTracker when you connect the integration. The key is stored like other integration credentials.
|
||||
|
||||
## What gets synced
|
||||
|
||||
- **Issues** are fetched from Linear (paginated) and upserted as **tasks** under per-team **projects** (created or matched by integration metadata).
|
||||
- **Task name**: `IDENTIFIER: title` (e.g. `ENG-42: Fix login`).
|
||||
- **Description**: issue URL (when available).
|
||||
- **Status**: mapped to `done` when the Linear workflow state name is done/completed/canceled (case-insensitive); otherwise `todo`.
|
||||
|
||||
Optional JSON **`custom_fields`** on tasks stores integration metadata (e.g. Linear identifier and URL) for matching on later syncs.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Field | Purpose |
|
||||
|--------|--------|
|
||||
| **API key** | Linear personal API key |
|
||||
| **Team keys (optional)** | Comma-separated team keys (e.g. `ENG,MOB`). If empty, issues from all accessible teams are considered (subject to API visibility). |
|
||||
| **Automatic sync** | When enabled, runs on the integration schedule like other connectors. |
|
||||
|
||||
Use **Test connection** to verify the key; use **Sync now** for a manual import.
|
||||
|
||||
## Limits and notes
|
||||
|
||||
- Sync walks up to a bounded number of GraphQL pages per run to avoid runaway imports.
|
||||
- The TimeTracker server must reach `https://api.linear.app`.
|
||||
- Webhooks are not required; sync is pull-based from Linear.
|
||||
@@ -90,6 +90,8 @@ Both apps support offline operation:
|
||||
3. **Automatic Sync**: When connection is restored, queued operations are processed
|
||||
4. **Conflict Resolution**: Server data takes precedence on conflict
|
||||
|
||||
The mobile app sends an **`Idempotency-Key`** on queued **`POST /api/v1/time-entries`** creates so retries after connectivity drops do not duplicate entries. See [REST API: Idempotent time entry creation](../api/REST_API.md#idempotent-time-entry-creation).
|
||||
|
||||
## Development
|
||||
|
||||
### Mobile App Development
|
||||
|
||||
@@ -31,6 +31,10 @@ ROUNDING_MINUTES=1
|
||||
SINGLE_ACTIVE_TIMER=true
|
||||
IDLE_TIMEOUT_MINUTES=30
|
||||
|
||||
# API token rate limits (per token; Redis recommended for multi-worker)
|
||||
# API_TOKEN_RATE_LIMIT_PER_MINUTE=100
|
||||
# API_TOKEN_RATE_LIMIT_PER_HOUR=1000
|
||||
|
||||
# User management
|
||||
ALLOW_SELF_REGISTER=true
|
||||
# Comma-separated admin usernames. Only the first username is automatically created during database initialization.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"product_scope": "TimeTracker (time tracking / billing). Energy stacks (e.g. Smappee, Modbus) are not in this repository.",
|
||||
"description": "Example JSON body for a Zapier Catch Hook (or custom webhook) when TimeTracker emits time_entry.created.",
|
||||
"event": "time_entry.created",
|
||||
"payload_shape": {
|
||||
"entry_id": 12345,
|
||||
"user_id": 1,
|
||||
"project_id": 42
|
||||
},
|
||||
"notes": "Configure an outbound webhook in TimeTracker for event type time_entry.created; Zapier's Webhooks by Zapier trigger can receive the same JSON your app sends."
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Add custom_fields JSON to tasks for integration metadata
|
||||
|
||||
Revision ID: 143_add_task_custom_fields
|
||||
Revises: 142_add_mail_test_recipient
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision = "143_add_task_custom_fields"
|
||||
down_revision = "142_add_mail_test_recipient"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
||||
try:
|
||||
return column_name in [col["name"] for col in inspector.get_columns(table_name)]
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "tasks" not in inspector.get_table_names():
|
||||
return
|
||||
if _has_column(inspector, "tasks", "custom_fields"):
|
||||
return
|
||||
try:
|
||||
op.add_column(
|
||||
"tasks",
|
||||
sa.Column("custom_fields", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
)
|
||||
except Exception:
|
||||
op.add_column("tasks", sa.Column("custom_fields", sa.JSON(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if "tasks" not in inspector.get_table_names():
|
||||
return
|
||||
if not _has_column(inspector, "tasks", "custom_fields"):
|
||||
return
|
||||
op.drop_column("tasks", "custom_fields")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Add api_idempotency_keys for safe API write retries
|
||||
|
||||
Revision ID: 144_api_idempotency_keys
|
||||
Revises: 143_add_task_custom_fields
|
||||
Create Date: 2026-04-05
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "144_api_idempotency_keys"
|
||||
down_revision = "143_add_task_custom_fields"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"api_idempotency_keys",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("api_token_id", sa.Integer(), nullable=False),
|
||||
sa.Column("scope", sa.String(length=128), nullable=False),
|
||||
sa.Column("key_hash", sa.String(length=64), nullable=False),
|
||||
sa.Column("response_status", sa.Integer(), nullable=False),
|
||||
sa.Column("response_body", sa.Text(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(["api_token_id"], ["api_tokens.id"], ondelete="CASCADE"),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("api_token_id", "scope", "key_hash", name="uq_api_idempotency_token_scope_key"),
|
||||
)
|
||||
op.create_index("ix_api_idempotency_keys_created_at", "api_idempotency_keys", ["created_at"], unique=False)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("ix_api_idempotency_keys_created_at", table_name="api_idempotency_keys")
|
||||
op.drop_table("api_idempotency_keys")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Add requires_approval and approval_level to quotes
|
||||
|
||||
Revision ID: 145_add_quotes_requires_approval
|
||||
Revises: 144_api_idempotency_keys
|
||||
Create Date: 2026-04-12
|
||||
|
||||
Idempotent: safe if columns already exist (partial upgrades).
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
|
||||
revision = "145_add_quotes_requires_approval"
|
||||
down_revision = "144_api_idempotency_keys"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(inspector, name: str) -> bool:
|
||||
try:
|
||||
return name in inspector.get_table_names()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
||||
try:
|
||||
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _has_table(inspector, "quotes"):
|
||||
return
|
||||
|
||||
quotes_columns = {c["name"] for c in inspector.get_columns("quotes")}
|
||||
|
||||
if "requires_approval" not in quotes_columns:
|
||||
op.add_column(
|
||||
"quotes",
|
||||
sa.Column("requires_approval", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
|
||||
if "approval_level" not in quotes_columns:
|
||||
op.add_column(
|
||||
"quotes",
|
||||
sa.Column("approval_level", sa.Integer(), nullable=False, server_default="1"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _has_table(inspector, "quotes"):
|
||||
return
|
||||
|
||||
if _has_column(inspector, "quotes", "approval_level"):
|
||||
op.drop_column("quotes", "approval_level")
|
||||
|
||||
if _has_column(inspector, "quotes", "requires_approval"):
|
||||
op.drop_column("quotes", "requires_approval")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Add position column to quote_items for line order
|
||||
|
||||
Revision ID: 146_add_quote_item_position
|
||||
Revises: 145_add_quotes_requires_approval
|
||||
Create Date: 2026-04-12
|
||||
|
||||
Idempotent: safe if column already exists.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "146_add_quote_item_position"
|
||||
down_revision = "145_add_quotes_requires_approval"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(inspector, name: str) -> bool:
|
||||
try:
|
||||
return name in inspector.get_table_names()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
||||
try:
|
||||
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _has_table(inspector, "quote_items"):
|
||||
return
|
||||
|
||||
if not _has_column(inspector, "quote_items", "position"):
|
||||
op.add_column(
|
||||
"quote_items",
|
||||
sa.Column("position", sa.Integer(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
# Backfill: stable order per quote (by id) -> 0, 1, 2, ...
|
||||
connection = op.get_bind()
|
||||
quote_ids = connection.execute(text("SELECT DISTINCT quote_id FROM quote_items ORDER BY quote_id")).fetchall()
|
||||
for (quote_id,) in quote_ids:
|
||||
rows = connection.execute(
|
||||
text("SELECT id FROM quote_items WHERE quote_id = :qid ORDER BY id"),
|
||||
{"qid": quote_id},
|
||||
).fetchall()
|
||||
for pos, (item_id,) in enumerate(rows):
|
||||
connection.execute(
|
||||
text("UPDATE quote_items SET position = :pos WHERE id = :iid"),
|
||||
{"pos": pos, "iid": item_id},
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _has_table(inspector, "quote_items"):
|
||||
return
|
||||
|
||||
if _has_column(inspector, "quote_items", "position"):
|
||||
op.drop_column("quote_items", "position")
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Add line_kind and optional fields to quote_items (issue #585)
|
||||
|
||||
Revision ID: 147_add_quote_item_line_kind
|
||||
Revises: 146_add_quote_item_position
|
||||
Create Date: 2026-04-12
|
||||
|
||||
Idempotent: safe if columns already exist.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect, text
|
||||
|
||||
revision = "147_add_quote_item_line_kind"
|
||||
down_revision = "146_add_quote_item_position"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(inspector, name: str) -> bool:
|
||||
try:
|
||||
return name in inspector.get_table_names()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _has_column(inspector, table_name: str, column_name: str) -> bool:
|
||||
try:
|
||||
return column_name in {c["name"] for c in inspector.get_columns(table_name)}
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
inspector = inspect(bind)
|
||||
|
||||
if not _has_table(inspector, "quote_items"):
|
||||
return
|
||||
|
||||
if not _has_column(inspector, "quote_items", "line_kind"):
|
||||
op.add_column(
|
||||
"quote_items",
|
||||
sa.Column("line_kind", sa.String(length=20), nullable=False, server_default="item"),
|
||||
)
|
||||
|
||||
if not _has_column(inspector, "quote_items", "display_name"):
|
||||
op.add_column("quote_items", sa.Column("display_name", sa.String(length=200), nullable=True))
|
||||
|
||||
if not _has_column(inspector, "quote_items", "category"):
|
||||
op.add_column("quote_items", sa.Column("category", sa.String(length=50), nullable=True))
|
||||
|
||||
if not _has_column(inspector, "quote_items", "line_date"):
|
||||
op.add_column("quote_items", sa.Column("line_date", sa.Date(), nullable=True))
|
||||
|
||||
if not _has_column(inspector, "quote_items", "sku"):
|
||||
op.add_column("quote_items", sa.Column("sku", sa.String(length=100), nullable=True))
|
||||
|
||||
connection = op.get_bind()
|
||||
connection.execute(text("UPDATE quote_items SET line_kind = 'item' WHERE line_kind IS NULL OR line_kind = ''"))
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
|
||||
for col in ("sku", "line_date", "category", "display_name", "line_kind"):
|
||||
inspector = inspect(bind)
|
||||
if not _has_table(inspector, "quote_items"):
|
||||
return
|
||||
if _has_column(inspector, "quote_items", col):
|
||||
op.drop_column("quote_items", col)
|
||||
@@ -124,6 +124,7 @@ class ApiClient {
|
||||
String? notes,
|
||||
String? tags,
|
||||
bool? billable,
|
||||
String? idempotencyKey,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'project_id': projectId,
|
||||
@@ -134,7 +135,13 @@ class ApiClient {
|
||||
if (tags != null) 'tags': tags,
|
||||
if (billable != null) 'billable': billable,
|
||||
};
|
||||
final res = await _dio.post<Map<String, dynamic>>('/api/v1/time-entries', data: body);
|
||||
final res = await _dio.post<Map<String, dynamic>>(
|
||||
'/api/v1/time-entries',
|
||||
data: body,
|
||||
options: idempotencyKey != null && idempotencyKey.isNotEmpty
|
||||
? Options(headers: {'Idempotency-Key': idempotencyKey})
|
||||
: null,
|
||||
);
|
||||
_throwIfError(res);
|
||||
return Map<String, dynamic>.from(res.data ?? {});
|
||||
}
|
||||
@@ -148,6 +155,7 @@ class ApiClient {
|
||||
String? notes,
|
||||
String? tags,
|
||||
bool? billable,
|
||||
String? ifUpdatedAt,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
if (projectId != null) 'project_id': projectId,
|
||||
@@ -157,6 +165,7 @@ class ApiClient {
|
||||
if (notes != null) 'notes': notes,
|
||||
if (tags != null) 'tags': tags,
|
||||
if (billable != null) 'billable': billable,
|
||||
if (ifUpdatedAt != null) 'if_updated_at': ifUpdatedAt,
|
||||
};
|
||||
final res = await _dio.patch<Map<String, dynamic>>('/api/v1/time-entries/$entryId', data: body);
|
||||
_throwIfError(res);
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import 'dart:developer' as developer;
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:timetracker_mobile/data/api/api_client.dart';
|
||||
import 'package:timetracker_mobile/data/models/time_entry.dart';
|
||||
import 'package:timetracker_mobile/data/storage/local_storage.dart';
|
||||
|
||||
/// RFC 4122-style random key (server allows up to 128 chars).
|
||||
String newSyncIdempotencyKey() {
|
||||
final r = Random.secure();
|
||||
final raw = List<int>.generate(16, (_) => r.nextInt(256));
|
||||
raw[6] = (raw[6] & 0x0f) | 0x40;
|
||||
raw[8] = (raw[8] & 0x3f) | 0x80;
|
||||
final hex = raw.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}';
|
||||
}
|
||||
|
||||
class SyncService {
|
||||
SyncService(this._api);
|
||||
|
||||
@@ -19,6 +33,7 @@ class SyncService {
|
||||
final q = await LocalStorage.getSyncQueue();
|
||||
q.add({
|
||||
'op': 'create_time_entry',
|
||||
'idempotency_key': newSyncIdempotencyKey(),
|
||||
'project_id': projectId,
|
||||
if (taskId != null) 'task_id': taskId,
|
||||
'start_time': startTime,
|
||||
@@ -39,6 +54,34 @@ class SyncService {
|
||||
await LocalStorage.setSyncQueue(q);
|
||||
}
|
||||
|
||||
/// Queue a PATCH for when offline; sends [if_updated_at] for optimistic locking (409 drops op).
|
||||
static Future<void> queueUpdateTimeEntry({
|
||||
required int entryId,
|
||||
String? ifUpdatedAt,
|
||||
int? projectId,
|
||||
int? taskId,
|
||||
String? startTime,
|
||||
String? endTime,
|
||||
String? notes,
|
||||
String? tags,
|
||||
bool? billable,
|
||||
}) async {
|
||||
final q = await LocalStorage.getSyncQueue();
|
||||
q.add({
|
||||
'op': 'update_time_entry',
|
||||
'entry_id': entryId,
|
||||
if (ifUpdatedAt != null) 'if_updated_at': ifUpdatedAt,
|
||||
if (projectId != null) 'project_id': projectId,
|
||||
if (taskId != null) 'task_id': taskId,
|
||||
if (startTime != null) 'start_time': startTime,
|
||||
if (endTime != null) 'end_time': endTime,
|
||||
if (notes != null) 'notes': notes,
|
||||
if (tags != null) 'tags': tags,
|
||||
if (billable != null) 'billable': billable,
|
||||
});
|
||||
await LocalStorage.setSyncQueue(q);
|
||||
}
|
||||
|
||||
Future<void> syncAll() async {
|
||||
await processQueue();
|
||||
await syncFromServer();
|
||||
@@ -55,6 +98,7 @@ class SyncService {
|
||||
final type = op['op']?.toString();
|
||||
try {
|
||||
if (type == 'create_time_entry') {
|
||||
final idem = op['idempotency_key']?.toString();
|
||||
await api.createTimeEntry(
|
||||
projectId: (op['project_id'] as num).toInt(),
|
||||
taskId: (op['task_id'] as num?)?.toInt(),
|
||||
@@ -63,13 +107,38 @@ class SyncService {
|
||||
notes: op['notes']?.toString(),
|
||||
tags: op['tags']?.toString(),
|
||||
billable: op['billable'] as bool?,
|
||||
idempotencyKey: idem != null && idem.isNotEmpty ? idem : null,
|
||||
);
|
||||
} else if (type == 'delete_time_entry') {
|
||||
await api.deleteTimeEntry((op['entry_id'] as num).toInt());
|
||||
} else if (type == 'update_time_entry') {
|
||||
final eid = (op['entry_id'] as num).toInt();
|
||||
await api.updateTimeEntry(
|
||||
eid,
|
||||
projectId: (op['project_id'] as num?)?.toInt(),
|
||||
taskId: (op['task_id'] as num?)?.toInt(),
|
||||
startTime: op['start_time']?.toString(),
|
||||
endTime: op['end_time']?.toString(),
|
||||
notes: op['notes']?.toString(),
|
||||
tags: op['tags']?.toString(),
|
||||
billable: op['billable'] as bool?,
|
||||
ifUpdatedAt: op['if_updated_at']?.toString(),
|
||||
);
|
||||
} else {
|
||||
remaining.add(op);
|
||||
}
|
||||
} catch (_) {
|
||||
} on DioException catch (e) {
|
||||
if (type == 'update_time_entry' && e.response?.statusCode == 409) {
|
||||
continue;
|
||||
}
|
||||
remaining.add(op);
|
||||
} catch (e, st) {
|
||||
developer.log(
|
||||
'Sync queue op failed (non-Dio): $type',
|
||||
name: 'SyncService',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
remaining.add(op);
|
||||
}
|
||||
}
|
||||
@@ -80,8 +149,12 @@ class SyncService {
|
||||
Future<void> syncFromServer() async {
|
||||
final api = _api;
|
||||
if (api == null) return;
|
||||
try {
|
||||
final res = await api.getTimeEntries(perPage: 200);
|
||||
|
||||
var page = 1;
|
||||
const perPage = 100;
|
||||
|
||||
while (true) {
|
||||
final res = await api.getTimeEntries(page: page, perPage: perPage);
|
||||
final raw = res['time_entries'] as List<dynamic>? ?? [];
|
||||
for (final e in raw) {
|
||||
if (e is Map) {
|
||||
@@ -89,6 +162,17 @@ class SyncService {
|
||||
await LocalStorage.saveTimeEntry(entry);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final pag = res['pagination'];
|
||||
var hasNext = false;
|
||||
if (pag is Map) {
|
||||
final hn = pag['has_next'];
|
||||
if (hn is bool) {
|
||||
hasNext = hn;
|
||||
}
|
||||
}
|
||||
if (!hasNext) break;
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:timetracker_mobile/core/config/app_config.dart';
|
||||
@@ -10,6 +11,9 @@ class SyncUseCase {
|
||||
final Connectivity _connectivity = Connectivity();
|
||||
Timer? _syncTimer;
|
||||
|
||||
/// Last sync error message for UI or support (cleared on successful sync start).
|
||||
static String? lastError;
|
||||
|
||||
SyncUseCase();
|
||||
|
||||
// Start periodic sync if auto-sync is enabled
|
||||
@@ -40,11 +44,17 @@ class SyncUseCase {
|
||||
|
||||
// Full sync: process queue and sync from server
|
||||
Future<bool> sync() async {
|
||||
lastError = null;
|
||||
try {
|
||||
final serverUrl = await AppConfig.getServerUrl();
|
||||
final token = await AuthService.getToken();
|
||||
|
||||
if (serverUrl == null || serverUrl.isEmpty || token == null || token.isEmpty) {
|
||||
lastError = 'Missing server URL or auth token';
|
||||
developer.log(
|
||||
'Sync skipped: $lastError',
|
||||
name: 'SyncUseCase',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -56,7 +66,14 @@ class SyncUseCase {
|
||||
await syncService.syncAll();
|
||||
|
||||
return true;
|
||||
} catch (_) {
|
||||
} catch (e, st) {
|
||||
lastError = e.toString();
|
||||
developer.log(
|
||||
'Sync failed: $e',
|
||||
name: 'SyncUseCase',
|
||||
error: e,
|
||||
stackTrace: st,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../domain/usecases/sync_usecase.dart';
|
||||
import '../providers/api_provider.dart';
|
||||
import '../../utils/auth/auth_service.dart';
|
||||
import '../providers/theme_mode_provider.dart';
|
||||
@@ -21,6 +22,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
bool _autoSync = true;
|
||||
bool _hasToken = false;
|
||||
String _version = '—';
|
||||
String? _lastSyncError;
|
||||
bool _syncing = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -46,11 +49,24 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
_autoSync = autoSync;
|
||||
_hasToken = token != null && token.isNotEmpty;
|
||||
_version = version;
|
||||
_lastSyncError = SyncUseCase.lastError;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runManualSync() async {
|
||||
setState(() => _syncing = true);
|
||||
final uc = SyncUseCase();
|
||||
await uc.sync();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_syncing = false;
|
||||
_lastSyncError = SyncUseCase.lastError;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _showThemePicker() {
|
||||
final themeMode = ref.read(themeModeProvider);
|
||||
showDialog<void>(
|
||||
@@ -286,6 +302,27 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _showSyncIntervalDialog,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.sync_problem_outlined),
|
||||
title: const Text('Last sync error'),
|
||||
subtitle: Text(
|
||||
_lastSyncError == null || _lastSyncError!.isEmpty ? 'None' : _lastSyncError!,
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: _syncing
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.cloud_upload_outlined),
|
||||
title: const Text('Sync now'),
|
||||
subtitle: const Text('Push queued changes and refresh from server'),
|
||||
onTap: _syncing ? null : _runManualSync,
|
||||
),
|
||||
_sectionHeader('Appearance'),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.palette),
|
||||
|
||||
@@ -7,9 +7,15 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='5.2.2',
|
||||
version='5.3.0',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
package_data={
|
||||
"app": [
|
||||
"resources/icc/*.icc",
|
||||
"resources/icc/LICENSE.txt",
|
||||
],
|
||||
},
|
||||
install_requires=[
|
||||
# Core requirements are in requirements.txt
|
||||
# This file is mainly for making the app importable during testing
|
||||
|
||||
@@ -230,6 +230,17 @@ def test_cii_handles_zero_tax():
|
||||
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
|
||||
# Zero-rated tax should use category Z
|
||||
assert "CategoryCode" in xml
|
||||
assert "ExemptionReason" in xml
|
||||
assert "VATEX-EU-O" in xml
|
||||
assert 'currencyID="EUR"' in xml
|
||||
# Should still produce valid CII
|
||||
passed, issues = validate_cii_en16931(xml)
|
||||
assert passed is True, f"Failed: {issues}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_cii_monetary_elements_have_currency_id():
|
||||
invoice = _make_invoice(currency_code="USD")
|
||||
xml, _ = build_cii_invoice_xml(invoice, _make_seller(), _make_buyer())
|
||||
assert 'currencyID="USD"' in xml
|
||||
assert xml.count('currencyID="USD"') >= 5
|
||||
|
||||
@@ -101,6 +101,7 @@ class TestQuoteInventoryIntegration:
|
||||
"item_quantity[]": ["5"],
|
||||
"item_price[]": ["25.00"],
|
||||
"item_unit[]": ["pcs"],
|
||||
"item_line_source[]": ["stock"],
|
||||
"item_stock_item_id[]": [str(test_stock_item.id)],
|
||||
"item_warehouse_id[]": [str(test_warehouse.id)],
|
||||
},
|
||||
@@ -114,12 +115,54 @@ class TestQuoteInventoryIntegration:
|
||||
assert quote is not None
|
||||
|
||||
# Check quote item has stock_item_id
|
||||
quote_item = quote.items.first()
|
||||
assert len(quote.items) >= 1
|
||||
quote_item = quote.items[0]
|
||||
assert quote_item is not None
|
||||
assert quote_item.stock_item_id == test_stock_item.id
|
||||
assert quote_item.warehouse_id == test_warehouse.id
|
||||
assert quote_item.is_stock_item is True
|
||||
|
||||
def test_quote_create_expense_and_good_lines(self, client, test_user, test_client):
|
||||
"""Quote form posts costs + extra goods as separate line_kind rows (#585)."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["_user_id"] = str(test_user.id)
|
||||
|
||||
response = client.post(
|
||||
url_for("quotes.create_quote"),
|
||||
data={
|
||||
"client_id": test_client.id,
|
||||
"title": "Mixed quote lines",
|
||||
"tax_rate": "0",
|
||||
"currency_code": "EUR",
|
||||
"qe_title[]": ["Trip"],
|
||||
"qe_description[]": ["Client visit"],
|
||||
"qe_category[]": ["travel"],
|
||||
"qe_amount[]": ["99.50"],
|
||||
"qe_date[]": ["2026-04-01"],
|
||||
"qg_name[]": ["License pack"],
|
||||
"qg_description[]": [""],
|
||||
"qg_category[]": ["license"],
|
||||
"qg_quantity[]": ["2"],
|
||||
"qg_unit_price[]": ["25"],
|
||||
"qg_sku[]": ["L-1"],
|
||||
},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
quote = Quote.query.filter_by(title="Mixed quote lines").first()
|
||||
assert quote is not None
|
||||
kinds = {i.line_kind for i in quote.items}
|
||||
assert "expense" in kinds
|
||||
assert "good" in kinds
|
||||
exp = next(i for i in quote.items if i.line_kind == "expense")
|
||||
assert exp.display_name == "Trip"
|
||||
assert exp.unit_price == Decimal("99.50")
|
||||
assert exp.quantity == Decimal("1")
|
||||
good = next(i for i in quote.items if i.line_kind == "good")
|
||||
assert good.display_name == "License pack"
|
||||
assert good.sku == "L-1"
|
||||
assert good.total_amount == Decimal("50.00")
|
||||
|
||||
def test_quote_send_reserves_stock(
|
||||
self, client, test_user, test_client, test_stock_item, test_warehouse, test_stock_with_quantity
|
||||
):
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for shared invoice PDF Factur-X / PDF/A post-processing."""
|
||||
|
||||
import io
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app import db
|
||||
from app.models import Client, Invoice, InvoiceItem, Project, User
|
||||
from app.utils.invoice_pdf_postprocess import postprocess_invoice_pdf_bytes
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_postprocess_noop_when_zugferd_disabled(app):
|
||||
with app.app_context():
|
||||
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
|
||||
settings.invoices_zugferd_pdf = False
|
||||
settings.invoices_pdfa3_compliant = False
|
||||
db.session.commit()
|
||||
raw = b"%PDF-1.4 minimal"
|
||||
out, e1, e2 = postprocess_invoice_pdf_bytes(raw, None, settings)
|
||||
assert out == raw and e1 is None and e2 is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_postprocess_embeds_when_zugferd_enabled(app):
|
||||
try:
|
||||
import pikepdf
|
||||
except ImportError:
|
||||
pytest.skip("pikepdf not installed")
|
||||
|
||||
with app.app_context():
|
||||
user = User(username="ppuser", role="user", email="pp@example.com")
|
||||
user.is_active = True
|
||||
user.set_password("x")
|
||||
db.session.add(user)
|
||||
client = Client(name="C1", email="c@example.com", address="Street 1")
|
||||
client.set_custom_field("peppol_country", "DE")
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
project = Project(name="P1", client_id=client.id, billable=True, hourly_rate=Decimal("50"))
|
||||
project.status = "active"
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
inv = Invoice(
|
||||
invoice_number="INV-PP-1",
|
||||
project_id=project.id,
|
||||
client_id=client.id,
|
||||
client_name=client.name,
|
||||
issue_date=date.today(),
|
||||
due_date=date.today() + timedelta(days=14),
|
||||
created_by=user.id,
|
||||
currency_code="EUR",
|
||||
tax_rate=Decimal("19"),
|
||||
)
|
||||
db.session.add(inv)
|
||||
db.session.commit()
|
||||
db.session.add(
|
||||
InvoiceItem(
|
||||
invoice_id=inv.id,
|
||||
description="Work",
|
||||
quantity=Decimal("1"),
|
||||
unit_price=Decimal("100.00"),
|
||||
)
|
||||
)
|
||||
db.session.commit()
|
||||
inv.calculate_totals()
|
||||
db.session.commit()
|
||||
|
||||
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
|
||||
settings.company_name = "Seller Co"
|
||||
settings.company_tax_id = "DE123456789"
|
||||
settings.peppol_sender_country = "DE"
|
||||
settings.peppol_sender_endpoint_id = "0088:123"
|
||||
settings.peppol_sender_scheme_id = "0088"
|
||||
settings.invoices_zugferd_pdf = True
|
||||
settings.invoices_pdfa3_compliant = False
|
||||
db.session.commit()
|
||||
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(595, 842))
|
||||
buf = io.BytesIO()
|
||||
pdf.save(buf)
|
||||
pdf.close()
|
||||
raw = buf.getvalue()
|
||||
|
||||
out, e1, e2 = postprocess_invoice_pdf_bytes(raw, inv, settings)
|
||||
assert e1 is None and e2 is None
|
||||
assert len(out) > len(raw)
|
||||
r = pikepdf.open(io.BytesIO(out))
|
||||
from app.utils.zugferd import FACTURX_EMBEDDED_FILENAME
|
||||
|
||||
assert FACTURX_EMBEDDED_FILENAME in r.attachments
|
||||
r.close()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_postprocess_returns_embed_error_on_invalid_pdf(app):
|
||||
with app.app_context():
|
||||
settings = __import__("app.models", fromlist=["Settings"]).Settings.get_settings()
|
||||
settings.invoices_zugferd_pdf = True
|
||||
settings.invoices_pdfa3_compliant = False
|
||||
db.session.commit()
|
||||
from types import SimpleNamespace
|
||||
|
||||
inv = SimpleNamespace(
|
||||
id=1,
|
||||
invoice_number="X",
|
||||
issue_date=date.today(),
|
||||
due_date=None,
|
||||
currency_code="EUR",
|
||||
subtotal=Decimal("0"),
|
||||
tax_rate=Decimal("0"),
|
||||
tax_amount=Decimal("0"),
|
||||
total_amount=Decimal("0"),
|
||||
notes=None,
|
||||
buyer_reference=None,
|
||||
project=None,
|
||||
client=None,
|
||||
client_name="B",
|
||||
client_email=None,
|
||||
client_address=None,
|
||||
items=[],
|
||||
expenses=[],
|
||||
extra_goods=[],
|
||||
)
|
||||
out, e1, e2 = postprocess_invoice_pdf_bytes(b"not pdf", inv, settings)
|
||||
assert e1 is not None
|
||||
assert out == b"not pdf"
|
||||
assert e2 is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@patch("app.utils.email.render_template", return_value="<html/>")
|
||||
@patch("app.utils.pdf_generator.InvoicePDFGenerator")
|
||||
@patch("app.utils.invoice_pdf_postprocess.postprocess_invoice_pdf_bytes")
|
||||
def test_build_invoice_email_payload_calls_postprocess(mock_pp, mock_igen, mock_render, app):
|
||||
"""Email PDF path applies the same Factur-X / PDF/A post-processing as export."""
|
||||
from app.utils.email import _build_invoice_email_payload
|
||||
|
||||
mock_igen.return_value.generate_pdf.return_value = b"raw_pdf"
|
||||
mock_pp.return_value = (b"processed_pdf", None, None)
|
||||
inv = MagicMock()
|
||||
inv.invoice_number = "INV-E-1"
|
||||
inv.issue_date = date(2025, 1, 10)
|
||||
inv.due_date = date(2025, 2, 10)
|
||||
inv.currency_code = "EUR"
|
||||
inv.total_amount = Decimal("100.00")
|
||||
with app.app_context():
|
||||
pdf, *_ = _build_invoice_email_payload(inv)
|
||||
mock_pp.assert_called_once_with(b"raw_pdf", inv, ANY)
|
||||
assert pdf == b"processed_pdf"
|
||||
@@ -181,18 +181,20 @@ def _minimal_cii_en16931() -> str:
|
||||
<ram:ApplicableHeaderTradeSettlement>
|
||||
<ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
|
||||
<ram:ApplicableTradeTax>
|
||||
<ram:CalculatedAmount>0.00</ram:CalculatedAmount>
|
||||
<ram:CalculatedAmount currencyID="EUR">0.00</ram:CalculatedAmount>
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:BasisAmount>100.00</ram:BasisAmount>
|
||||
<ram:BasisAmount currencyID="EUR">100.00</ram:BasisAmount>
|
||||
<ram:CategoryCode>Z</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>0.00</ram:RateApplicablePercent>
|
||||
<ram:ExemptionReason>Not subject to VAT</ram:ExemptionReason>
|
||||
<ram:ExemptionReasonCode>VATEX-EU-O</ram:ExemptionReasonCode>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
<ram:LineTotalAmount>100.00</ram:LineTotalAmount>
|
||||
<ram:TaxBasisTotalAmount>100.00</ram:TaxBasisTotalAmount>
|
||||
<ram:LineTotalAmount currencyID="EUR">100.00</ram:LineTotalAmount>
|
||||
<ram:TaxBasisTotalAmount currencyID="EUR">100.00</ram:TaxBasisTotalAmount>
|
||||
<ram:TaxTotalAmount currencyID="EUR">0.00</ram:TaxTotalAmount>
|
||||
<ram:GrandTotalAmount>100.00</ram:GrandTotalAmount>
|
||||
<ram:DuePayableAmount>100.00</ram:DuePayableAmount>
|
||||
<ram:GrandTotalAmount currencyID="EUR">100.00</ram:GrandTotalAmount>
|
||||
<ram:DuePayableAmount currencyID="EUR">100.00</ram:DuePayableAmount>
|
||||
</ram:SpecifiedTradeSettlementHeaderMonetarySummation>
|
||||
</ram:ApplicableHeaderTradeSettlement>
|
||||
<ram:IncludedSupplyChainTradeLineItem>
|
||||
@@ -204,7 +206,7 @@ def _minimal_cii_en16931() -> str:
|
||||
</ram:SpecifiedTradeProduct>
|
||||
<ram:SpecifiedLineTradeAgreement>
|
||||
<ram:NetPriceProductTradePrice>
|
||||
<ram:ChargeAmount>100.00</ram:ChargeAmount>
|
||||
<ram:ChargeAmount currencyID="EUR">100.00</ram:ChargeAmount>
|
||||
</ram:NetPriceProductTradePrice>
|
||||
</ram:SpecifiedLineTradeAgreement>
|
||||
<ram:SpecifiedLineTradeDelivery>
|
||||
@@ -215,9 +217,11 @@ def _minimal_cii_en16931() -> str:
|
||||
<ram:TypeCode>VAT</ram:TypeCode>
|
||||
<ram:CategoryCode>Z</ram:CategoryCode>
|
||||
<ram:RateApplicablePercent>0.00</ram:RateApplicablePercent>
|
||||
<ram:ExemptionReason>Not subject to VAT</ram:ExemptionReason>
|
||||
<ram:ExemptionReasonCode>VATEX-EU-O</ram:ExemptionReasonCode>
|
||||
</ram:ApplicableTradeTax>
|
||||
<ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
<ram:LineTotalAmount>100.00</ram:LineTotalAmount>
|
||||
<ram:LineTotalAmount currencyID="EUR">100.00</ram:LineTotalAmount>
|
||||
</ram:SpecifiedTradeSettlementLineMonetarySummation>
|
||||
</ram:SpecifiedLineTradeSettlement>
|
||||
</ram:IncludedSupplyChainTradeLineItem>
|
||||
@@ -266,7 +270,7 @@ def test_validate_cii_en16931_detects_missing_line_items():
|
||||
@pytest.mark.unit
|
||||
def test_validate_cii_en16931_detects_missing_grand_total():
|
||||
cii = _minimal_cii_en16931().replace(
|
||||
"<ram:GrandTotalAmount>100.00</ram:GrandTotalAmount>", ""
|
||||
'<ram:GrandTotalAmount currencyID="EUR">100.00</ram:GrandTotalAmount>', ""
|
||||
)
|
||||
passed, issues = validate_cii_en16931(cii)
|
||||
assert passed is False
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Tests for PDF/A-3 conversion with ICC profile embedding."""
|
||||
import io
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -141,3 +142,33 @@ def test_convert_to_pdfa3_returns_error_on_invalid_pdf(app):
|
||||
out_bytes, err = convert_to_pdfa3(b"not a pdf")
|
||||
assert err is not None
|
||||
assert out_bytes == b"not a pdf"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_bundled_srgb_icc_file_present():
|
||||
"""Shipped nano sRGB profile is on disk for PDF/A DestOutputProfile."""
|
||||
from app.utils.pdfa3 import _BUNDLED_SRGB_ICC
|
||||
|
||||
assert _BUNDLED_SRGB_ICC.is_file(), f"Missing bundled ICC: {_BUNDLED_SRGB_ICC}"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.skipif(not pikepdf, reason="pikepdf not installed")
|
||||
def test_verapdf_when_invoice_verapdf_path_configured(app):
|
||||
"""Optional: full minimal PDF → PDF/A-3 pipeline checked with veraPDF if path is set."""
|
||||
from app.utils.invoice_validators import validate_pdfa_verapdf
|
||||
from app.utils.pdfa3 import convert_to_pdfa3
|
||||
|
||||
verapdf_path = (os.environ.get("INVOICE_VERAPDF_PATH") or "").strip()
|
||||
if not verapdf_path or not os.path.isfile(verapdf_path):
|
||||
pytest.skip("INVOICE_VERAPDF_PATH not set (optional CI/local check)")
|
||||
|
||||
pdf = pikepdf.Pdf.new()
|
||||
pdf.add_blank_page(page_size=(595, 842))
|
||||
buf = io.BytesIO()
|
||||
pdf.save(buf)
|
||||
pdf.close()
|
||||
out, err = convert_to_pdfa3(buf.getvalue())
|
||||
assert err is None
|
||||
passed, msgs = validate_pdfa_verapdf(out, verapdf_path=verapdf_path)
|
||||
assert passed is True, f"veraPDF reported: {msgs}"
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Web UI tests for quotes (regression: issue #583 create -> view 500)."""
|
||||
|
||||
from flask import url_for
|
||||
|
||||
|
||||
def test_create_quote_redirect_then_view_returns_200(admin_authenticated_client, test_client, app):
|
||||
with app.app_context():
|
||||
create_url = url_for("quotes.create_quote")
|
||||
|
||||
resp = admin_authenticated_client.post(
|
||||
create_url,
|
||||
data={
|
||||
"client_id": str(test_client.id),
|
||||
"title": "Regression Quote 583",
|
||||
"tax_rate": "0",
|
||||
"currency_code": "EUR",
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert resp.status_code in (302, 303), resp.data[:500]
|
||||
location = resp.headers.get("Location", "")
|
||||
assert "/quotes/" in location
|
||||
|
||||
view_resp = admin_authenticated_client.get(location, follow_redirects=False)
|
||||
assert view_resp.status_code == 200, view_resp.data[:500]
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for bulk time entry actions."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
|
||||
@patch("app.services.time_entry_bulk_service.safe_commit")
|
||||
@patch("app.services.time_entry_bulk_service.db")
|
||||
def test_bulk_all_active_entries_returns_400(mock_db, mock_safe_commit):
|
||||
from app.services.time_entry_bulk_service import apply_bulk_time_entry_actions
|
||||
|
||||
e = MagicMock()
|
||||
e.user_id = 1
|
||||
e.is_active = True
|
||||
|
||||
mock_q = MagicMock()
|
||||
mock_q.all.return_value = [e]
|
||||
|
||||
with patch("app.services.time_entry_bulk_service.TimeEntry") as TE:
|
||||
TE.query.filter.return_value = mock_q
|
||||
result = apply_bulk_time_entry_actions(
|
||||
[99],
|
||||
"set_billable",
|
||||
True,
|
||||
user_id=1,
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["http_status"] == 400
|
||||
assert "active" in result["error"].lower()
|
||||
mock_db.session.rollback.assert_called()
|
||||
mock_safe_commit.assert_not_called()
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for integration sync helpers."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = [pytest.mark.unit]
|
||||
|
||||
|
||||
def test_sync_result_item_count_prefers_synced_count():
|
||||
from app.utils.integration_sync_context import sync_result_item_count
|
||||
|
||||
assert sync_result_item_count({"synced_count": 5, "synced_items": 9}) == 5
|
||||
|
||||
|
||||
def test_sync_result_item_count_falls_back_to_synced_items():
|
||||
from app.utils.integration_sync_context import sync_result_item_count
|
||||
|
||||
assert sync_result_item_count({"synced_items": 7}) == 7
|
||||
|
||||
|
||||
def test_sync_result_item_count_empty():
|
||||
from app.utils.integration_sync_context import sync_result_item_count
|
||||
|
||||
assert sync_result_item_count({}) == 0
|
||||
assert sync_result_item_count(None) == 0
|
||||
|
||||
|
||||
@patch("app.models.Task")
|
||||
def test_find_task_by_integration_ref_filters_by_source(MockTask):
|
||||
from app.utils.integration_sync_context import find_task_by_integration_ref
|
||||
|
||||
t_git = MagicMock()
|
||||
t_git.custom_fields = {"integration": {"source": "github", "ref": "same-ref"}}
|
||||
t_jira = MagicMock()
|
||||
t_jira.custom_fields = {"integration": {"source": "jira", "ref": "same-ref"}}
|
||||
MockTask.query.filter_by.return_value.all.return_value = [t_git, t_jira]
|
||||
|
||||
assert find_task_by_integration_ref(42, "same-ref", source="jira") is t_jira
|
||||
assert find_task_by_integration_ref(42, "same-ref", source="github") is t_git
|
||||
|
||||
|
||||
@patch("app.models.Task")
|
||||
def test_find_task_by_integration_ref_without_source_matches_any(MockTask):
|
||||
from app.utils.integration_sync_context import find_task_by_integration_ref
|
||||
|
||||
first = MagicMock()
|
||||
first.custom_fields = {"integration": {"source": "github", "ref": "r1"}}
|
||||
MockTask.query.filter_by.return_value.all.return_value = [first]
|
||||
|
||||
assert find_task_by_integration_ref(1, "r1") is first
|
||||
@@ -112,6 +112,12 @@ def test_embed_facturx_xml_in_pdf_adds_cii_attachment(app):
|
||||
# Must contain the Factur-X guideline ID
|
||||
assert "urn:cen.eu:en16931:2017" in xml_content
|
||||
|
||||
# ZUGFeRD / Factur-X: primary invoice XML uses AFRelationship Data and text/xml
|
||||
fs = result.attachments[FACTURX_EMBEDDED_FILENAME]
|
||||
assert fs.obj["/AFRelationship"] == pikepdf.Name("/Data")
|
||||
emb = fs.obj["/EF"]["/F"]
|
||||
assert emb.get("/Subtype") == pikepdf.Name("/text/xml")
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_embed_facturx_xml_has_correct_xmp_metadata(app):
|
||||
|
||||
Reference in New Issue
Block a user