631 Commits

Author SHA1 Message Date
Dries Peeters 52c7e9f02a feat(ai): gate helper by default; add uninstall docs and release 5.5.6
- Honor AI_ENABLED across session AI, REST v1, LLM service, templates, and
  context; add regression tests for the AI helper gate.
- Docker Compose: optional Ollama behind the ai profile; align env.example
  and example compose with safe defaults.
- Add UNINSTALL.md with a dedicated AI teardown section; cross-link from
  README, INSTALLATION, Getting Started, docs index, and Docker setup guide.
- Record 5.5.6 in CHANGELOG and sync version examples in BUILD_CONFIGURATION;
  bump setup.py to 5.5.6.
2026-05-14 06:46:59 +02:00
Dries Peeters e7e0376dce chore(api): re-export _get_client_id_from_session helper
The legacy api.py module exposes _get_client_id_from_session, which is
imported by client-portal tests via app.routes.api. Surface it through
the package __init__ so the import works regardless of which loader
path is taken.
2026-05-14 06:22:24 +02:00
Dries Peeters 4469ea8720 fix(client_portal): tolerate non-JSON dashboard preferences body
Use request.get_json(silent=True) so a request with the wrong
Content-Type or a malformed body falls through to the existing
validation path and returns 400 instead of raising a 415/parse error.
2026-05-14 06:22:19 +02:00
Dries Peeters 7446b86a36 fix(audit): reuse session connection when checking audit_logs table
Inspecting the engine directly opens a second connection, which under
SQLite can contend with a write lock held by the current flush and
cause spurious failures during tests. Bind the inspector to the
session's existing connection when available and fall back to the
engine otherwise.
2026-05-14 06:22:15 +02:00
Dries Peeters 0718da2585 fix(inventory): soft-delete suppliers via HTML delete route
Match the behaviour of the API v1 endpoint by marking the supplier as
inactive instead of hard-deleting it. This preserves referential
integrity with purchase orders, stock items and audit data and removes
the "cannot delete supplier with associated stock items" failure mode.
2026-05-14 06:22:11 +02:00
Dries Peeters 2ed3bd6a88 feat(pdf-designer): multi-select, text alignment, guides, layers (#619)
Add multi-select with Shift/Ctrl toggles and marquee selection on the invoice
and quote PDF template editors, plus a shared transformer, group nudge with
snap-aware arrow keys, multi copy/paste/duplicate, align-to-page or
align-to-selection, horizontal/vertical distribute, and Ctrl/Cmd+G group /
Ctrl/Cmd+Shift+G ungroup.

Expose horizontal (left/center/right/justify) and vertical (top/middle/bottom)
text controls in the properties panel; export verticalAlign, group_id,
locked, and hidden in template JSON where set. Skip hidden elements in
ReportLab rendering; draw justified text on canvas via Paragraph; honor
verticalAlign when a text box height is present.

Introduce app/static/js/pdf_editor helpers (loaded with dynamic import from
the templates), pdf_editor.css, a layers panel with visibility and lock toggles,
smart-guide snapping, and optional ruler chrome. Document behaviour and manual
QA in docs/PDF_LAYOUT_EDITOR.md; add tests/test_pdf_template_schema.py for
optional template fields.

Closes #619.
2026-05-13 19:49:38 +02:00
Dries Peeters 1836cb3c2d chore(typing): resolve mypy errors and harden type checking
Drives ``mypy app/`` from 567 errors in 208 files to 0 errors across the
376 source files checked by ``./scripts/run-ci-local.sh code-quality``.

Configuration & dependencies
- pyproject.toml: enable implicit_optional (Flask-style ``x: str = None``
  defaults), silence truthy-function/truthy-bool (legitimate import-guard
  checks like ``KanbanColumn``), and disable warn_return_any (SQLAlchemy
  1.x ``Query`` API returns Any pervasively). Add module overrides for
  ``app.models.*``, repositories, base CRUD service, and known
  ``joinedload`` / ``Query.paginate`` callers where mypy cannot model the
  Flask-SQLAlchemy runtime API without a plugin.
- requirements-test.txt: pin ``types-requests``, ``types-bleach``,
  ``types-Markdown``, ``types-python-dateutil`` so mypy stops complaining
  about missing stubs.

Latent bugs fixed while driving mypy to zero
- app/utils/logger.py, app/utils/datetime_utils.py: drop imports of
  symbols that don't exist (``get_performance_metrics``,
  ``from_app_timezone``, ``to_app_timezone``) — these would have raised
  at import time on first use.
- app/services/currency_service.py: ``from typing import Decimal`` was a
  bug (typing has no Decimal); switch to ``decimal.Decimal`` and rename
  the ``D`` alias.
- app/utils/env_validation.py, app/utils/role_migration.py: ``Dict[str,
  any]`` → ``Dict[str, Any]`` (built-in ``any`` is not a type).
- app/utils/email.py: introduce ``send_template_email`` and update the
  three callers (``client_approval_service``,
  ``client_notification_service``, ``workflow_engine``) that were
  passing ``to=``/``template=``/etc. to ``send_email`` whose signature
  doesn't accept them — calls would have raised TypeError at runtime.
- app/services/permission_service.py: rewrite ``grant_permission`` /
  ``revoke_permission`` to use the actual ``Role`` ↔ ``Permission``
  many-to-many relationship; the old code referenced non-existent
  ``Permission.role_id`` / ``Permission.granted`` columns.
- app/services/gps_tracking_service.py: pass the required ``title`` and
  ``expense_date`` fields when creating mileage ``Expense`` rows.
- app/services/workflow_engine.py: ``_perform_action`` now forwards the
  ``rule`` argument to ``_action_log_time``, and ``_action_webhook``
  short-circuits when ``url`` is missing.
- app/services/time_tracking_service.py: validate ``start_time`` /
  ``end_time`` before comparing them.
- app/services/export_service.py: build CSV in a ``StringIO`` then wrap
  the bytes in ``BytesIO`` — ``csv.writer`` requires text I/O.
- app/integrations/peppol_smp.py: avoid attribute access on ``None`` in
  the SMP ``href`` fallback.
- app/integrations/{github,gitlab,slack}.py: coerce query-string params
  to strings so ``requests.get(params=...)`` matches the typed signature
  (and is what the HTTP layer expects anyway).
- app/integrations/{xero,quickbooks}.py: guard ``get_access_token()``
  returning ``None`` before calling private ``_api_request`` helpers.

Annotation-only changes
- Add ``Dict[str, Any]`` / ``list`` / ``Optional[...]`` annotations to
  service dict-literals that mypy could not infer from heterogeneous
  values (``ai_suggestion_service``, ``ai_categorization_service``,
  ``custom_report_service``, ``unpaid_hours_service``,
  ``integration_service``, ``invoice_service``, ``backup_service``,
  ``inventory_report_service``, ``analytics_service``, etc.).
- ``app/utils/event_bus.py``: ``emit_event`` accepts ``str |
  WebhookEvent`` and normalizes to ``str`` so all call-sites type-check.
- ``app/utils/api_responses.py``: introduce ``ApiResponse`` alias for
  ``Response | tuple[Response, int] | tuple[str, int]``.
- ``app/utils/budget_forecasting.py``: forecasting helpers return
  ``Optional[Dict]`` (they already returned ``None`` when the project
  was missing).
- ``app/utils/pdf_generator_reportlab.py``: ``_normalize_color`` is
  ``Optional[str]``.
- ``app/utils/pdfa3.py``: remove invalid ``force_version=None`` retry
  call.
- Narrow ``type: ignore`` markers on optional-dependency fallbacks
  (``redis``, ``bleach``, ``markdown``, ``babel``,
  ``powerpoint_export``) and on the documented ``requests.Session``
  / ``RotatingFileHandler`` typeshed limitations.
2026-05-13 10:32:06 +02:00
Dries Peeters 786d88bdba style: apply black 24.8.0 and isort across app/
Pure formatting pass to satisfy ``./scripts/run-ci-local.sh code-quality``:
no behavioural changes, just consistent line wrapping, import ordering,
and trailing-newline normalization across routes, models, services, and
utility modules.
2026-05-13 10:31:39 +02:00
Dries Peeters 657874f6c1 refactor(quotes): structure quote create POST with stepped validation
Wrap create flow in a single try/except with explicit step labels for
logging and diagnostics. Harden logging around current_user and keep
validation/flash behavior while making failures easier to trace.
2026-05-13 09:12:08 +02:00
Dries Peeters 98f173c5f7 fix(telemetry): quiet OpenTelemetry during pytest unless opted in
Skip network OTLP export when TESTING is set unless OTEL_ENABLE_IN_TESTS=1.
Register shutdown once at exit, reset providers cleanly in tests, and
briefly silence OTEL loggers during shutdown to avoid noisy CI output.
2026-05-13 09:12:05 +02:00
Dries Peeters 83dbfb51df fix(settings): unwrap scoped session when detecting flush state
Flask-SQLAlchemy exposes a scoped_session proxy; reading _flushing on the
proxy did not reflect the underlying session, so Settings.get_settings()
could run add/commit mid-flush and break tests. Unwrap to the real session
before checking _flushing and _warn_on_events.
2026-05-13 09:12:03 +02:00
Dries Peeters 4514e1883e fix(ui): repair main shell DOM and use full column width
Remove stray closing </div> tags from admin backups, admin API
tokens, and quote detail templates so content and modals stay
inside #mainContent and the footer aligns with the main column.

Drop max-w-7xl from the primary shell; wrap main + attribution in
a flex column so the scroll area fills width beside the sidebar.
Align the support banner inner row to full width.

Documented in CHANGELOG.md under [Unreleased].
2026-05-12 20:18:46 +02:00
Dries Peeters 1ddea89d40 Harden full-database restore and document operational behaviour
Admin restore runs in a background thread; the finally block must not use current_app.logger outside an application context. Use the captured Flask app instance for safe_file_remove logging instead.

While restore_backup runs (extract through Alembic upgrade), set a per-app _database_restore_in_progress flag and expose is_database_restore_in_progress(). The client portal blueprint registers a global app_context_processor; get_current_client() now skips database access during restore and catches SQLAlchemy errors with session rollback so error pages and login can still render when the schema is briefly torn on PostgreSQL.

Documentation: add docs/admin/BACKUP_AND_RESTORE.md, link it from the admin index and import/export docs, cross-reference from DATABASE_RECOVERY.md, and extend IMPORT_EXPORT_GUIDE.md with concurrent-restore guidance.
2026-05-11 07:13:04 +02:00
Dries Peeters 2f838adeee Merge pull request #616 from MacJediWizard/upstream-fix/markdown-filter-normalize-toastui
fix(markdown): unescape Toast UI Editor output and render strikethrough
2026-05-08 15:18:08 +02:00
Dries Peeters 2196379036 Merge pull request #615 from MacJediWizard/upstream-fix/tasks-edit-create-orphan-div
fix(ui): close orphan div tags breaking task edit/create layout
2026-05-08 15:17:56 +02:00
MacJediWizard d7a1eac247 style(template_filters): apply black 24.8.0 formatting
Match upstream's pinned black version so the file lands clean in
CI. All four hunks are line-joins under the 88-char limit, no
behavioural change.
2026-05-07 14:51:26 -04:00
MacJediWizard 6dd74a861a fix(markdown): render ~~strikethrough~~ in task and note descriptions
CommonMark and GFM both define ~~text~~ as strikethrough, and Toast UI
emits it when the user toggles strikethrough in the WYSIWYG editor.
Python markdown's 'extra' extension does not implement strikethrough,
so the wrapping tildes leak through to the rendered HTML and the user
sees ~~text~~ instead of struck-through text on /tasks/<id>, notes,
client view, etc.

Add a regex pass to _normalize_toastui_markdown that converts
~~text~~ to <del>text</del> before markdown parsing. The bleach
sanitizer already permits <del> via the existing allowed_tags list,
so the rendered HTML survives the sanitization pass intact.

The regex is non-greedy and stays on a single line so multiple
strikethroughs on the same line each get their own pair.
2026-05-07 11:41:36 -04:00
MacJediWizard 7394af7940 fix(markdown): unescape Toast UI Editor over-escaping in markdown filter
Task and note descriptions saved through the Toast UI WYSIWYG editor
came back wrapped in CommonMark-style escapes that Python markdown
either does not honour (\, → literal \,) or honours in a way that
breaks rendering (line-leading \- prevents list parsing).

The visible symptom on /tasks/<id>: bullet lists rendered as a single
flat paragraph with literal backslashes peppered between words.

Add a normaliser run before _md.markdown(...) that:
- Restores line-leading bullets that the editor escaped (\-/\*/\+
  followed by whitespace at start of line).
- Strips backslashes before punctuation Python markdown does not
  recognise as a valid escape (commas, colons, semicolons, etc.).

This handles existing rows in the DB without any data migration. The
normaliser leaves alone backslashes before punctuation Python markdown
does handle natively (\. \( \) \+ \- mid-line, \* \_ \# etc.)
so author-intent escapes still render correctly.

Strikethrough (~~text~~) still does not render because the 'extra'
extension does not include it; that is a separate enhancement.
2026-05-07 11:36:15 -04:00
MacJediWizard a77461a5c8 fix(ui): close orphan </div> tags breaking task edit/create layout
Same class as the projects/list.html and weekly_goals/index.html fix
shipped earlier. Both task templates had a stray </div> immediately
before the <style> block with no matching opener:

  app/templates/tasks/edit.html:339
  app/templates/tasks/create.html:192

The orphan close pushed the rest of the page outside the outer
.grid lg:grid-cols-3 wrapper, so the form and sidebar rendered
left-aligned and the 'Built by an independent developer' footer
floated up to the top right.

Verified by div-balance trace: both files now reach final balance 0
with no negative excursions.
2026-05-06 15:40:44 -04:00
MacJediWizard 76c8235355 fix(kanban): include on_hold in last-ditch validator fallback
The validator's last-ditch fallback (used when both project-specific
and global columns are missing) returned a hardcoded list that omitted
"on_hold". The function's own docstring on the same code path even
calls this out explicitly:

    drops to globally-defined columns like "on_hold" come back as 400
    "Invalid status".

The broader fix from PR #605 made the validator fall back to global
columns first, which fixes the common case. But the very last fallback
list — used during fresh migrations before the kanban_columns table is
seeded — still rejects "on_hold" tasks the user has already created.
Real installs that ship with on_hold columns enabled hit this on the
first request after a clean migration.

Add on_hold to the hardcoded list so it stays consistent with what
on_hold-enabled installs expect to validate.

Auto-lint reformatted the surrounding column declarations; the only
behavioral change is the addition of on_hold to the fallback list.
2026-05-06 12:19:30 -04:00
Dries Peeters 0ba5dca51a Merge remote-tracking branch 'Origin/main' into develop 2026-05-06 08:12:06 +02:00
Dries Peeters 2b26b9a224 Merge pull request #612 from MacJediWizard/upstream-fix/client-approval-enum-values
fix(approvals): bind ClientApprovalStatus enum values to PG, not names
2026-05-06 08:11:28 +02:00
MacJediWizard 0a8fbd8329 fix(approvals): bind ClientApprovalStatus enum values to PG, not names
ClientApprovalStatus is defined with uppercase Python names and lowercase
string values (PENDING = "pending", etc.), but the Postgres enum type
clientapprovalstatus is defined with the lowercase values. SQLAlchemy
defaults to binding the enum *name*, so every query against the column
sent "PENDING" and Postgres rejected it with InvalidTextRepresentation.

This made get_pending_approvals_for_client raise on every client portal
request — the navbar context processor catches the exception and returns
0, but the stack trace was logged on every page load.

Pass values_callable to SQLEnum so SQLAlchemy uses the enum *value* (the
lowercase string PG actually stores).

The auto-lint hook reformatted the rest of the file; the only behavioral
change is the values_callable parameter on the status column.
2026-05-06 01:14:53 -04:00
MacJediWizard ea6524fb3c fix(clients): remove nested <script> tag that orphaned confirmDeleteNote
The conditional invoice-unbilled-time IIFE was wrapped in its own
<script>...</script> inside an outer <script> block. Browsers do not
allow nesting; the inner </script> closed the outer script, leaving
confirmDeleteNote and the trailing </script> as raw HTML. The function
rendered as visible text at the bottom of the client detail page and
was unreachable, breaking the per-note Delete button.

Removing the nested tags lets the IIFE run inline within the outer
script and restores confirmDeleteNote.
2026-05-06 00:59:21 -04:00
Dries Peeters 830fb64b75 Merge remote-tracking branch 'Origin/main' into develop 2026-05-06 06:44:58 +02:00
Dries Peeters b7f6ac4b7e Updated both files before merging main 2026-05-06 06:44:48 +02:00
Dries Peeters c079054ced Merge pull request #610 from MacJediWizard/upstream-fix/page-layout-broken-divs
fix(ui): close orphan </div> tags breaking projects + weekly_goals layout
2026-05-06 06:42:04 +02:00
Dries Peeters 89e2d86826 Merge pull request #609 from MacJediWizard/fix/upstream-donate-ui-license-aware-everywhere
fix(ui): hide remaining donate widgets on licensed instances (companion to #603)
2026-05-06 06:41:51 +02:00
Dries Peeters 6d12756a21 Merge pull request #608 from MacJediWizard/feat/upstream-oidc-role-sync
feat(oidc): map OIDC groups to RBAC Role rows on login (additive default)
2026-05-06 06:41:36 +02:00
Dries Peeters b8eeeb5705 Merge pull request #607 from MacJediWizard/fix/upstream-task-service-status
fix(tasks): service-layer status validator + preview JS now kanban-aware (companion to #606)
2026-05-06 06:41:22 +02:00
Dries Peeters bcb3cf6fdb Merge branch 'main' into fix/upstream-uploads-and-validator-drift 2026-05-06 06:40:56 +02:00
Dries Peeters a6e0b59b70 Merge pull request #605 from MacJediWizard/fix/upstream-kanban-validator-fallback
fix(kanban): validator falls back to global columns when project has no specifics
2026-05-06 06:40:14 +02:00
Dries Peeters 078c840257 Merge pull request #604 from MacJediWizard/fix/upstream-runtime-bugs
fix: two runtime bugs flagged by flake8 (NameError in auth, UnboundLocalError in timer)
2026-05-06 06:39:55 +02:00
MacJediWizard f7f4962151 fix(ui): close orphan </div> tags breaking projects + weekly_goals layout
projects/list.html had an extra </div> at line 216 (after listView's
closing tag) that pushed the rest of the page outside the projectsContainer
wrapper, causing the grid to render unstyled and the footer to bleed up
to the top-right.

weekly_goals/index.html had an extra </div> at line 223 just before
{% endblock %} with no matching open, producing the same broken-wrapper
effect.

Both pages now render centered with the standard footer position.
2026-05-05 17:03:47 -04:00
MacJediWizard 6969f9444e fix(ui): hide remaining donate widgets on licensed instances
Companion to #603. That earlier PR added is_license_activated guards
to three donate UI gates (header support button, user-menu support
link, support modal donate/buy-license buttons). Six other donate
gates in templates were missed:

  - app/templates/base.html:1187            (sidebar nav donate entry)
  - app/templates/base.html:1356            (large dismissible support banner)
  - app/templates/main/help.html:834, 841   (help-page donate prompts)
  - app/templates/main/about.html:43        (about-page donate header)
  - app/templates/main/dashboard.html:629   (dashboard donate widget)
  - app/templates/reports/index.html:18     (reports-page donate prompt)

Each was gated only on `current_user.ui_show_donate` (per-user flag),
not on the instance-wide is_license_activated. So a licensed instance
where some users still had the default ui_show_donate=true would keep
showing donate prompts to those users — most prominently the big
amber-gradient banner in base.html that draws the eye on every page.

Repro: with settings.donate_ui_hidden=true (license active), log in
as a user whose ui_show_donate is still the default true, and observe
the banner at the top of every page plus the help/dashboard/reports/
about prompts — none of which respect the active license.

Fix: append `and not is_license_activated` to the six leaking
visibility guards. Mirror of the pattern in #603.

The two about.html gates at lines 189 and 196 already check
donate_ui_hidden (functionally equivalent to is_license_activated)
and are left untouched.

5 files, +7 / -7 (or +9 / -9 when combined with #603's base.html
edits in this same branch). No backend, schema, or behavioural
change beyond the template visibility guard.

Note on overlap with #603: this branch's base.html includes both
PR #603's changes and this PR's changes. If #603 lands first, the
base.html diff in this PR shrinks to the +2/-2 unique to it.
If this PR lands first, #603 is subsumed and can be closed.
2026-05-05 16:42:22 -04:00
MacJediWizard 8b8271d548 feat(oidc): map OIDC groups to RBAC Role rows on login (additive default)
Wires the OIDC groups claim into the RBAC Role table introduced by
migration 030 (super_admin, admin, manager, user, viewer).

Until now, OIDC could only set the legacy users.role="admin" column
via OIDC_ADMIN_GROUP. Nothing in the codebase ever assigned Role rows
from OIDC, which meant IdP groups could not grant super_admin,
manager, or any custom role through SSO — only the binary is_admin
flag through the legacy column.

Three new env vars, all opt-in:

  OIDC_ROLE_GROUP_MAP — JSON map of OIDC group name -> Role name.
    Example:
      OIDC_ROLE_GROUP_MAP='{"app-admin":"admin","app-manager":"manager"}'
    Empty/invalid JSON disables the feature; OIDC_ADMIN_GROUP keeps
    working unchanged.

  OIDC_ROLE_SYNC_MODE — "additive" (default) or "sync".
    additive: only ADD Role rows matching the user's groups; never
              revoke. Misconfigured map degrades to a no-op.
    sync:     also REMOVE mapped Role rows when the matching group
              is gone from the user's claims.

  OIDC_NEVER_REVOKE_USER_IDS — comma-separated user IDs that must
    never have roles revoked by OIDC sync, regardless of mode.
    Useful for protecting bootstrap admins against a misconfigured
    map in sync mode.

Implementation in app/routes/auth.py runs after the existing
OIDC_ADMIN_GROUP block. Steps on each OIDC login:

  1. Parse the user's groups claim against OIDC_ROLE_GROUP_MAP -> a
     set of target Role names.
  2. Look up matching Role rows in DB (silently skips names that do
     not exist as Role rows).
  3. ADD: any target Role the user does not already have.
  4. REMOVE: only in sync mode, only Role rows whose name is in the
     map's values (so manually-assigned roles outside the OIDC scope
     are preserved), and only if the user id is not in
     OIDC_NEVER_REVOKE_USER_IDS.
  5. Commit through safe_commit; failures log a warning and continue.

Defensive JSON parsing in config.py handles empty/missing input,
invalid JSON, non-dict roots (array, null, number), and falsy
keys/values — all degrade to {} (no-op). A warning is logged on the
first OIDC callback after a parse failure so a misconfigured env var
surfaces in the app log without crashing the app.

OIDC_ROLE_SYNC_MODE defaults to "additive" for any value other than
exactly "additive" or "sync" so typos default to safe.
OIDC_NEVER_REVOKE_USER_IDS ignores non-integer entries.

Why additive default: a misconfigured OIDC_ROLE_GROUP_MAP in sync
mode would silently revoke every mapped role on the next login,
including the bootstrap super_admin if the IdP claims do not include
the configured group. Additive mode means a misconfigured map
degrades to a no-op, not a lockout.

Backward compatible: every existing OIDC deployment without these
env vars set keeps identical behaviour. OIDC_ADMIN_GROUP is
untouched.

2 files, +103 / -0. No schema change, no data migration.
2026-05-01 17:30:23 -04:00
MacJediWizard 6454ed4550 fix(tasks): service-layer status validator + preview JS now kanban-aware
Companion to #606. The route validator at tasks.py:223 already calls
KanbanColumn.get_valid_status_keys(), but two downstream spots still
silently re-introduced the old hardcoded 5-key behaviour.

1. app/services/task_service.py:46
   `VALID_STATUSES = ("todo", "in_progress", "review", "done", "cancelled")`
   was a class-level hardcoded tuple. create_task() at line 85 silently
   coerced any status not in the tuple to TaskStatus.TODO.value. So a
   user creating a task with initial status "on_hold" would have it
   quietly clamped to "todo" at the service layer even though the route
   accepted it.

2. app/templates/tasks/create.html and tasks/edit.html
   The status preview badge — both the server-rendered Jinja chain
   in create.html and the client-side updateBadge() JS map in both
   templates — hardcoded the same 5 keys. Selecting "on_hold" in the
   dropdown caused the JS lookup to miss and fall back to the first
   map entry ("To Do"), so the preview lied even before the form was
   submitted.

Fix 1 (service): create_task() now calls
KanbanColumn.get_valid_status_keys(project_id=project_id) to build
the allowed set per call. The VALID_STATUSES tuple is kept as a
last-ditch fallback for the table-not-yet-seeded path and extended
to include "on_hold" so even the fallback matches the default seed.

Fix 2 (templates): the Jinja preview chain in create.html now loops
over kanban_columns to find the matching label. The JS updateBadge()
map in both create.html and edit.html now generates entries from
{% for col in kanban_columns %}, so any configured column key works
without further code changes.

3 files, +25 / -13. No schema change, no data migration.
2026-05-01 17:21:22 -04:00
MacJediWizard 101eb4abf4 fix(uploads,kanban): write all attachment routes to mounted volume; eliminate validator-drift bugs
Two distinct fix sets in one commit, both extending the kanban validator
fix in PR #605 and the project_attachments path-resolution fix already
discussed in this repo's history.

PHASE A — five upload routes joined current_app.root_path + ".." +
"uploads/<X>", which on a deployed instance with the standard
docker-compose layout resolves to /app/uploads/<X>. That path is
outside the mounted app_uploads volume, so every upload returns 500
with PermissionError. Same defect as project_attachments.

  - app/routes/team_chat.py:470 (chat attachments)
  - app/routes/clients.py:1257 (client attachments)
  - app/routes/comments.py:279 (comment attachments)
  - app/routes/quotes.py:1120 (quote attachments)
  - app/routes/client_portal.py:1330,1347 (legacy "uploads/" download
    fallback branches — same join, same bug)

Fix prepends "app/static/" so the resolved path lands inside the
mounted volume at /app/app/static/uploads/<X>. Mirrors the
invoice_images and quote_images patterns elsewhere in the same files.

PHASE B — validator/UI drift bugs, same class as the kanban fix in #605.

  - app/models/kanban_column.py
      * new helper get_columns_with_global_fallback() — returns
        project columns or falls back to globals; mirrors
        get_valid_status_keys behaviour for templates
      * last-ditch hardcoded fallback in get_valid_status_keys now
        includes "on_hold" so the table-not-yet-seeded path matches
        the keys initialize_default_columns seeds
  - app/routes/tasks.py
      * task_counts now initialises from kanban_columns instead of
        the hardcoded 4 keys; tasks in cancelled/on_hold/custom
        columns are counted in the summary cards
      * create-task validator now calls get_valid_status_keys(project_id)
        instead of a hardcoded 5-key tuple; users creating a task in
        on_hold no longer silently get clamped to todo
      * every render_template("tasks/create.html", ...) and
        ("tasks/edit.html", ...) now passes kanban_columns
  - app/templates/tasks/create.html and tasks/edit.html
      * status <option> list now loops over kanban_columns instead of
        hardcoding 5 keys
  - app/routes/invoices.py:832
      * bulk-update validator now accepts "issued", mirroring the
        single-update validator at line 623; the model supports it
  - app/routes/quotes.py:920, 1026, 1320
      * admin-notification queries now use User.is_admin (which
        considers both the legacy role column AND Role rows) instead
        of User.query.filter_by(role="admin", ...). RBAC-only admins
        granted via the Role table are now notified on quote.sent,
        quote.accepted, and quote.approval.requested.

10 files, +60 / -41. No schema change. No data migration.
2026-05-01 16:00:14 -04:00
MacJediWizard 945340f609 fix(kanban): validator falls back to global columns when project has no specifics
PUT /api/tasks/<id>/status returns 400 "Invalid status" whenever the
task belongs to a project that has no project-specific kanban_columns
rows AND the user drops it into a configured global column other than
the four hardcoded fallback keys.

Reproduction:

  1. Project has no project-specific kanban_columns rows.
  2. The instance has 5 globals (project_id IS NULL): todo, in_progress,
     review, done, on_hold.
  3. The kanban UI renders the 5 globals as drop targets for that
     project's tasks.
  4. User drops a task into "On Hold". Frontend sends status="on_hold".
  5. app/routes/tasks.py:1519 calls
       KanbanColumn.get_valid_status_keys(project_id=task.project_id)
     with the project's id.
  6. get_active_columns(project_id=<id>) filters strictly on project_id
     and returns [].
  7. get_valid_status_keys then falls back to the hardcoded list
       ["todo", "in_progress", "review", "done", "cancelled"]
     which is missing "on_hold" (and includes "cancelled", which isn't
     even a configured column).
  8. "on_hold" is not in that list -> 400.

Drops to the four hardcoded keys all returned 200; only "On Hold"
failed, exactly matching the live 200/400 alternation observed in
production logs.

Fix: when there are no project-specific columns, fall back to the
configured global columns from the database (which is the set the UI
is already rendering). The hardcoded list is only used as a last-ditch
fallback when even the globals table is empty - this preserves the
table-not-yet-seeded safety net during fresh migrations.

Pure validator change; no schema change, no behavioural change beyond
accepting the statuses the UI is already offering.
2026-05-01 15:02:50 -04:00
MacJediWizard 49a4a26b78 fix: two runtime bugs flagged by flake8 in v5.5.2
These were caught by the project's own flake8 step but the failing
checks have been red on a number of recent runs, suggesting it's worth
fixing the underlying defects rather than ignoring the rule.

1. app/routes/auth.py — F821: undefined name 'datetime'

   `current_user.two_factor_confirmed_at = datetime.utcnow()` (line ~620)
   used `datetime` without importing it. Confirming 2FA raises
   `NameError: name 'datetime' is not defined` at runtime.
   Adds `from datetime import datetime` to the imports.

2. app/routes/timer.py — F823: local variable '_' referenced before assignment

   `from flask_babel import gettext as _` is imported at module scope.
   Four functions then unpack `can_start, _ = TimeTrackingService().can_start_timer(...)`
   which makes `_` a function-local for the entire enclosing scope and
   shadows the i18n alias. Three earlier `flash(_("..."))` calls in the
   same functions (lines 171, 449, 2019) reference the local before it
   exists and raise `UnboundLocalError` at runtime.

   Fix: rename the throwaway slot from `_` to `_unused` in all four
   `can_start_timer` unpackings. The translation alias resolves cleanly
   in every flash() call again.

Total: +6 / -4 across two files.
2026-05-01 14:46:37 -04:00
MacJediWizard a24776131b fix(ui): hide donate UI on instances with an activated supporter license
The header support button, the user-menu support link, and the donate /
buy-license buttons inside the support modal were rendered for every
authenticated user, including instances with `donate_ui_hidden = true`
(an activated supporter license). Other donate prompts (sidebar, dashboard,
about, reports, help) already gated on `is_license_activated`; these three
spots slipped through.

Wrap each in `{% if not is_license_activated %}` so a licensed instance
gets a clean UI. The "Love TimeTracker? Share it" button stays visible —
sharing is still useful regardless of license state. Modal title copy
already adapts via the existing `is_license_activated` branch.
2026-05-01 14:24:38 -04:00
Dries Peeters 115af37168 feat(admin): undo/redo and wheel zoom for invoice and quote PDF editors
Extract snapshot reload from saved-design loading and reuse it for
history restore so undo matches save semantics.

Keep a capped stack of stage.toJSON() snapshots with debounced pushes
after drags, transforms, property panel edits, alignment/layer moves,
adds, deletes, and related actions.

Wire Ctrl/Cmd+Z and Ctrl/Cmd+Y (plus Ctrl/Cmd+Shift+Z for redo)
outside focused inputs; add non-passive wheel handling on the canvas
container to zoom within existing scale limits.

Document shortcuts and wheel zoom in the editor info box (i18n-ready).
2026-04-30 06:17:24 +02:00
Dries Peeters 9d4be6feec fix(admin): prefer form template_json for invoice PDF preview
When the layout editor posts template_json with the preview request,
use it instead of loading only the saved database template. Preview
then matches unsaved canvas edits and avoids stale layouts.

Normalize page width/height from the selected page size when parsing
form JSON; fall back to the stored template if the body is missing
or invalid.

Add a regression test ensuring form JSON overrides DB content.
2026-04-30 06:17:21 +02:00
Dries Peeters 887c93f00c fix(quotes): align list/detail scope with quote edit permissions
Ensure quote list/detail access uses shared quote scope resolution so users with quote-management permissions can view records they can edit, including post-edit redirects in web and API flows. Add regression coverage for non-admin edit_quotes behavior and document the scope-alignment requirement in advanced permissions docs.
2026-04-29 10:27:00 +02:00
Dries Peeters eb7b1be05f feat(i18n): add Portuguese (pt) locale and translation scaffold
Register Português in LANGUAGES and normalize pt-BR/pt-PT (and similar)
to pt in _normalize_locale so Accept-Language and stored preferences resolve
to translations/pt/.

Add translations/pt/LC_MESSAGES/messages.po seeded from English msgids;
translators can fill msgstr incrementally.

Extend i18n tests for pt presence and catalog file. Update translation
docs (TRANSLATION_SYSTEM, CONTRIBUTING_TRANSLATIONS, implementation note).
2026-04-29 06:53:24 +02:00
Dries Peeters ac74218fc9 refactor(ui): unify bottom-right FAB dock and refresh docs
Replace the separate plus and bolt floating controls with a single Actions menu inside #fabDock, driven by app/static/floating-actions.js. The dock stacks Actions, optional team chat, and AI Helper using shared CSS variables for spacing; the AI control is a circular FAB matching the other buttons.

Move the chat widget panel to a fixed viewport overlay so dock z-index no longer paints controls over the open panel, and lift the panel bottom when the admin version banner or mobile bottom nav applies. Fade non-actions dock children while the actions menu is open (fab-dock--menu-open).

Update README.md, docs/UI_GUIDELINES.md, and the advanced-features implementation summaries so contributors describe the floating hub instead of global-fab.js. Keep app/static/quick-actions.js aligned with the retired mount pattern for any remaining references.
2026-04-27 22:22:00 +02:00
Dries Peeters bf4c34ff83 feat(docker): bundle Ollama and wire AI helper in compose
- Add ollama and ollama-init services with ollama_data volume; app waits for the model pull and receives AI_* defaults (AI_BASE_URL=http://ollama:11434).

- Document bundled stack, env vars, and Ollama vs host base URL in README and DOCKER_COMPOSE_SETUP.

- Align env.example AI defaults with the compose stack.

- fix(ai): include api_key_set on AIProviderConfig so from_settings(**get_ai_config()) matches the settings dict.
2026-04-27 21:54:03 +02:00
Dries Peeters 5d4e693a2b Add LDAP setup wizard on Integrations and admin routes
Introduce a guided LDAP configuration wizard mirroring the OIDC flow:
five-step UI with server/TLS, bind, directory layout, groups and
AUTH_METHOD, then optional connection test and .env / Docker Compose
generation for copy-paste deployment.

- Refactor LDAPService.test_connection to accept an optional config
  mapping so the wizard can test draft values without merging live env
  secrets; keep POST /admin/ldap/test on current_app.config.
- Add GET /admin/ldap/setup-wizard plus POST endpoints for test,
  validate, and generate-config (manage_settings, rate limited).
- Surface an LDAP card with status badge and wizard link on the
  integrations list for admins and manage_settings users.
- Add tests for validate, generate, and wizard test delegation.
2026-04-27 20:21:34 +02:00
Dries Peeters 6c57ba775a fix(templates): remove stray closing divs on import/export and list pages
Extra or unmatched </div> tags inside {% block content %} closed layout
ancestors early, which broke the centered main column and stacked modals
and scripts incorrectly.

- import_export/index.html: drop duplicate grid closer
- saved_filters/list.html: remove orphan closer after page body
- time_entry_templates/list.html: same orphan pattern as saved filters
2026-04-27 20:21:28 +02:00
Dries Peeters 6c8e86cd01 fix(timer): respect Settings.single_active_timer at runtime
Timer starts always blocked a second running entry and never read the\nadmin-controlled Settings flag.\n\n- Add TimeTrackingService.can_start_timer() using Settings.get_settings()\n  and wire it into start_timer, web timer routes, kiosk start, and\n  legacy POST /api/timer/resume.\n- POST /api/v1/timer/start returns 409 with error_code\n  timer_already_running when single-active mode is on and a timer\n  is already running.\n- Deduplicate start_timer template handling in the service.\n\nTests: tests/test_single_active_timer_setting.py.\nDocs: REST_API (responses), GETTING_STARTED, REQUIREMENTS, Docker env\nnotes, TESTING_STRATEGY, env.example comment; CHANGELOG entry.
2026-04-27 19:16:25 +02:00