Merge pull request #586 from DRYTRIX/rc/v5.3.0

Rc/v5.3.0
This commit is contained in:
Dries Peeters
2026-04-12 14:05:58 +02:00
committed by GitHub
85 changed files with 3980 additions and 954 deletions
+2 -2
View File
@@ -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
+5
View File
@@ -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 (1365, 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.32.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.
+4
View File
@@ -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
+3
View File
@@ -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
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+252
View File
@@ -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,
},
]
}
+2 -2
View File
@@ -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"),
+2
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -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",
+23
View File
@@ -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
View File
@@ -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,
}
+2 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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")
+9 -6
View File
@@ -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
View File
@@ -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)
+91 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -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)
+19 -8
View File
@@ -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),
}
+93
View File
@@ -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)
+11
View File
@@ -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
+1 -1
View File
@@ -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) */
+10 -1
View File
@@ -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>
+26 -1
View File
@@ -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') }}
+10 -4
View File
@@ -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, '&quot;');
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>
+89 -187
View File
@@ -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, '&quot;') + '">' + 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, '&quot;') : '') + '" 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
View File
@@ -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, '&quot;') + '">' + 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, '&quot;') : '') + '" 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 %}
+5 -1
View File
@@ -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>
+11 -2
View File
@@ -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
View File
@@ -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
+77
View File
@@ -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
+153
View File
@@ -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
View File
@@ -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
+9
View File
@@ -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
+11
View File
@@ -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}"
+57
View File
@@ -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)
+153
View File
@@ -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
+43
View File
@@ -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
+26 -1
View File
@@ -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)
+19 -1
View File
@@ -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
View File
@@ -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
+15 -2
View File
@@ -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
View File
@@ -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)
+27 -25
View File
@@ -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
```
+3 -1
View File
@@ -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
+55
View File
@@ -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
+17 -1
View File
@@ -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
+32
View File
@@ -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.
+2
View File
@@ -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
+4
View File
@@ -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)
+10 -1
View File
@@ -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);
+88 -4
View File
@@ -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;
}
}
}
+18 -1
View File
@@ -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 -1
View File
@@ -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
+11
View File
@@ -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
):
+154
View File
@@ -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"
+13 -9
View File
@@ -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
+31
View File
@@ -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}"
+25
View File
@@ -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
+6
View File
@@ -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):