Move the "Unreleased" notes for the personal GitHub/Google Calendar/
Slack connectors, custom themes, productivity dashboard, AI time entry
suggestions, project forecast panel, and break/end-of-day reminders
under a dated 5.6.0 release header that matches setup.py. Refresh the
"What's New" section in README.md with 5.6.0 highlights and links to
the feature docs.
Three new opt-in integration connectors that plug into the existing
`app/integrations/base.py:BaseConnector` pattern and the integrations
settings UI. Each connector subclasses `BaseConnector`, persists its
state inside the existing `Integration.config` JSONB (no new tables),
encrypts every secret at rest via `app/utils/secret_crypto`, and
degrades gracefully when the integration row is missing or
`is_active=False` -- every method returns
`{"ok": false, "error": "Integration not configured"}` without
raising, so the timer, exports, and dashboards keep working when a
connector is disabled or broken.
All third-party HTTP calls go through `requests` with a 10-second
timeout and a `try/except requests.RequestException`. Tokens are
never written to logs in their raw form -- only short
`xoxb-...` / `ghp_...` truncations.
GitHub connector (`app/integrations/github_connector.py`, provider
key `github_connector`):
- Webhook receiver at `POST /api/integrations/github/webhook`
verifies `X-Hub-Signature-256` with HMAC-SHA256 against the
per-integration webhook secret before reading the payload.
- Handles `issues.opened` (creates a task with
`external_ref="github_issue_{n}"`, mapped priority and `todo`
status), `issues.assigned` (optionally starts a timer for the
linked TimeTracker user when `users.github_username` matches),
`issues.closed` (marks the existing task `done`), and `ping`.
- Manual sync (`POST /api/integrations/github/sync`, admin only)
pulls open issues from
`GET /repos/{owner}/{repo}/issues?state=open&per_page=50` and
upserts tasks by `external_ref`. Optional `label_filter`.
Google Calendar connector (`app/integrations/google_calendar_connector.py`,
provider key `google_calendar_connector`):
- OAuth2 flow at `/integrations/google/{connect,callback,disconnect}`
using raw `requests` against
`https://oauth2.googleapis.com/token`. Tokens (`access_token`,
`refresh_token`, `token_expiry`) are stored encrypted in
`Integration.config`. `client_id`/`client_secret` come from
Flask config (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`) and are
never hardcoded.
- `_refresh_token_if_needed()` refreshes within 5 minutes of expiry
on every API call.
- `sync()` supports `import` / `export` / `both`:
* import: pulls dated events from the configured `calendar_id`
over the last `sync_days_back` days (clamped 1-30), skips
all-day events and anything tagged `[TT]` or already linked via
`gcal:{event_id}` in the notes of an existing `TimeEntry`.
* export: posts completed entries created since `last_sync_at`
back to Google as `[TT] {project} -- {task or notes}` events
with `timeZone: "UTC"`.
- `revoke()` calls `https://oauth2.googleapis.com/revoke` and
wipes the stored tokens.
- APScheduler job `google_calendar_sync` runs every 30 minutes;
each user is wrapped in `try/except` so one broken token cannot
block the rest.
Slack connector (`app/integrations/slack_connector.py`, provider key
`slack_connector`):
- Webhook receiver at `POST /api/integrations/slack/events`
verifies `X-Slack-Signature` (HMAC-SHA256 of
`v0:{timestamp}:{body}`) and rejects requests older than 5
minutes. Replies to Slack's URL verification handshake
immediately.
- Slash command `/tt` supports `start [project]` (id or
case-insensitive partial name match against the user's allowed
projects), `stop`, `status`, `today` (via
`notification_service.get_today_summary_for_user`), and an
in-place help text fallback. Every reply is ephemeral JSON so it
fits inside Slack's 3-second budget without touching
`response_url`.
- `notify_timer_started` / `notify_timer_stopped` post a
stopwatch/checkmark message to the configured channel. Wired
into both the page route (`app/routes/timer.py`) and the JSON
API (`app/routes/api.py`) as a fire-and-forget hook: the import
+ call are wrapped in `try/except` and only log at `DEBUG` on
failure, so Slack outages can't slow down the timer flow.
- `post_daily_summary` posts a daily roll-up; APScheduler job
`slack_daily_summary` runs every 30 minutes and matches each
user's configured `HH:MM` against the window.
Plumbing and storage:
- New blueprint `app/routes/integrations_webhooks.py` registers
the webhook receivers (`csrf.exempt`, signature-verified) plus a
uniform `config`/`status`/`test` API surface
(`/api/integrations/{github,google,slack}/{config,status,test}`)
used by the settings UI. Optional-registered in
`app/blueprint_registry.py`.
- Alembic revision `155_add_integration_columns`:
* `users.github_username` (String(100), nullable) - GitHub login
join key for the assignment auto-start-timer flow.
* `tasks.external_ref` (String(200), nullable, indexed) -
canonical external id for connector-created tasks; the new
index lets webhook receivers de-duplicate cheaply.
Both columns are added defensively (inspector-checked) so the
migration is safe to re-run.
- New cards in `app/templates/integrations/_connector_cards.html`
(included by `templates/integrations/list.html`) drive the
Personal connectors UI -- Tailwind CSS only, vanilla JS, per-card
status fetch, save, test, and sync actions.
Documentation:
- `docs/integrations/README.md` indexes all built-in connectors.
- `docs/integrations/GITHUB_CONNECTOR.md`,
`docs/integrations/GOOGLE_CALENDAR.md`, and
`docs/integrations/SLACK.md` cover setup, OAuth/webhook wiring,
config fields, endpoints, and operational notes for each
connector.
- `docs/api/REST_API.md` lists the new endpoints under a new
"Personal integration connectors" subsection.
- `CHANGELOG.md` notes the feature under the [Unreleased] section.
`LLMService`, `TimeTrackingService`, `ForecastService`, and the
`Integration` model schema are intentionally untouched -- only
`users` and `tasks` gain columns via migration.
Adds a per-user theme picker under Settings → Custom theme. Users can
pick one of eight built-in themes (default, ocean, forest, sunset,
lavender, rose, slate, high-contrast) and optionally override accent
colour, sidebar style, text size and corner radius. Preferences are
persisted on the users table and applied on every page load through a
single <style id="tt-theme-vars"> block injected into base.html.
The picker (app/templates/components/theme_picker.html) is self-
contained vanilla JS: clicks debounce a POST /api/user/theme that
returns the regenerated CSS block, so changes preview instantly with
no page reload, and only persist when Save is clicked.
The default theme generates an empty CSS block, so existing users see
zero visual change until they opt in. ThemeService reads every user
attribute through getattr() and falls back to defaults on exception,
so an unmigrated database never breaks rendering. All values that end
up in the generated CSS are validated against the strict #RRGGBB regex
or explicit allow-lists before being embedded.
- Alembic revision 156_add_user_theme_columns adds five nullable
columns to the users table (defensively — each column is only added
if missing): theme_name, theme_accent_color, theme_sidebar_style,
theme_font_size, theme_border_radius.
- ThemeService (app/services/theme_service.py): BUILT_IN_THEMES dict,
ACCENT_PRESETS palette, get_theme_css_vars / get_all_themes /
validate_accent_color / save_user_theme.
- New API: GET / POST /api/user/theme (both @login_required).
- New context processor inject_theme exposes theme_css on every
request; base.html renders it right after the existing CSS link
tags.
- Documented in docs/features/CUSTOM_THEMES.md; CHANGELOG and README
updated to reflect the feature.
Introduce a dedicated My productivity page at /dashboard/productivity
with streaks, focus metrics, project breakdown, a 12-week heatmap, and
Chart.js charts backed by ProductivityService (user-timezone-aware).
Expose GET /api/productivity/stats with a 5-minute cache when no active
timer is running. Document the feature and related session JSON routes.
Introduce ForecastService for deterministic velocity, budget, timeline,
and task metrics plus optional LLM narratives. Expose GET
/api/projects/<id>/forecast with a 10-minute in-memory cache, and add a
self-contained forecast panel to active budgeted projects. Document the
feature in the budget forecasting and project dashboard guides.
Introduce GET /api/ai/suggest with deterministic AISuggestionService results
and optional LLM enrichment (?rich=true), plus reusable suggestion chips,
notes ghost autocomplete, and a manual-entry Autofill popover. All UI hides
when AI is disabled; LLM failures degrade to deterministic suggestions only.
Extend smart notifications with break-interval and end-of-day wrap-up
kinds, user settings, migration 154, idle.js toasts, and a 15-minute
scheduler job for optional Web Push. Document new kinds and env defaults.
Generic images set APP_VERSION=dev-0 by default, which hid the real
semver in the UI (e.g. vdev-0). Treat that value as unset so setup.py
wins unless a real override is set; add a regression test.
TestCacheIntegration::test_get_cache_with_redis_enabled and
test_get_cache_fallback_to_memory were both failing with:
RuntimeError: Working outside of application context.
The tests patched `app.utils.cache.current_app` with a MagicMock and
set `mock_app.config = {...}`. In theory that should isolate get_cache()
from Flask's LocalProxy entirely. In practice the RuntimeError still
fires — most likely because (a) module-level `_cache` left over from a
prior test holds a reference that triggers a LocalProxy access at
return time, or (b) a code path inside the patched function still
reaches the real `current_app` past the mock boundary.
Rather than diagnose the exact mock-interaction issue, rewrite both
tests to use the real `app` fixture from conftest.py:
- Set `REDIS_ENABLED` / `REDIS_URL` / `REDIS_DEFAULT_TTL` on the real
Flask config inside `with app.app_context():`.
- Reset `app.utils.cache._cache = None` explicitly so the global
doesn't leak state between tests.
- Keep the `patch("app.utils.cache.RedisCache")` since we don't want
the test to actually hit Redis; only the read of `current_app.config`
needs to be real.
This is closer to how get_cache() runs in production (real Flask
context, mocked external dependency) and avoids the LocalProxy ↔ mock
collision entirely.
Test plan
- pytest tests/test_utils/test_cache.py::TestCacheIntegration::test_get_cache_with_redis_enabled
- pytest tests/test_utils/test_cache.py::TestCacheIntegration::test_get_cache_fallback_to_memory
- pytest tests/test_utils/test_cache.py (the other 16 tests in the file should be unaffected)
`TestWebhookService::test_deliver_webhook_timeout` and
`test_deliver_webhook_http_error` both assert
`delivery.status == 'failed'` after a request error, but the test
fixture creates the `Webhook` with the model default `max_retries=3`.
The delivery flow at app/utils/webhook_service.py:127-141 does:
delivery.mark_failed(error_type='timeout', ...)
...
WebhookService._schedule_retry(delivery, webhook)
and `_schedule_retry` at line 244 calls `delivery.mark_retrying(...)`
when `retry_count < max_retries`. With max_retries=3, the very first
attempt gets retried, so `delivery.status` ends as 'retrying' rather
than 'failed'.
The two tests are checking *immediate* failure handling, not retry
orchestration. Setting `max_retries=0` on the fixture isolates that
concern. Retry behaviour itself is covered by
`test_retry_failed_deliveries`, which can use a separate webhook
with retries enabled if needed.
No service-code change required — the service is doing the right thing
for the production scenario (webhooks retry by default).
Test plan
- pytest tests/test_utils/test_webhook_service.py::TestWebhookService::test_deliver_webhook_timeout
- pytest tests/test_utils/test_webhook_service.py::TestWebhookService::test_deliver_webhook_http_error
- pytest tests/test_utils/test_webhook_service.py::TestWebhookService::test_deliver_webhook_success (unchanged behaviour)
Sixteen tests in tests/test_routes/test_api_v1_*_refactored.py and
test_api_v1_recurring_invoices_credit_notes.py reference fixtures
(mileage, payment, expense, credit_note, recurring_invoice, quote) that
were never defined anywhere — not in conftest.py, not in the test files
themselves. Every affected test fails as ERROR (fixture-setup failure)
with pytest's "fixture not found" message.
Add the missing fixtures to tests/conftest.py, mirroring the existing
patterns used for the `invoice` / `project` / `test_client` fixtures:
- mileage: bound to user + project, populated with minimal valid trip
- expense: bound to user + project, "travel" category, today's date
- payment: bound to the test invoice, completed bank_transfer
- credit_note: bound to invoice + user, unique credit_number derived
from invoice.id to avoid collisions
- recurring_invoice: monthly retainer template tied to project + client
- quote: draft quote tied to test_client and user
Each fixture commits via the function-scoped app fixture (so it ties
into the per-test sqlite isolation), refreshes the ORM instance, and
returns it. No models touched. No tests touched.
Test plan
- pytest tests/test_routes/test_api_v1_mileage_refactored.py
- pytest tests/test_routes/test_api_v1_payments_refactored.py
- pytest tests/test_routes/test_api_v1_recurring_invoices_credit_notes.py
- pytest tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py
- pytest tests/test_routes/test_api_v1_quotes_refactored.py
- All previously-erroring tests should now reach their assertions
All 23 tests in `tests/test_project_costs.py` were failing as ERROR
(fixture-setup failure) with all-23-test impact from one root cause.
Root cause: the file's local `app` fixture passes
`{"TESTING": True, "SQLALCHEMY_DATABASE_URI": "sqlite:///:memory:",
"WTF_CSRF_ENABLED": False}` to `create_app(...)`. It does not supply
`SECRET_KEY`. The Unit Tests CI job does not export `FLASK_ENV`, so
`create_app` loads `ProductionConfig` which inherits
`SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-production")`.
The production-safety guard at `app/__init__.py:180-187` then raises
`ValueError: SECRET_KEY must be set explicitly in production.` The
exception fires inside the fixture, so every test in the file is
reported as ERROR before `db.create_all()` runs.
The guard was added in 531f16b5 (2026-03-06); the local fixture
predates it and was left behind. Every other test module uses the
project-wide `app` fixture in `tests/conftest.py:200` which already
sets both `FLASK_ENV: "testing"` and a non-sentinel `SECRET_KEY`.
Fix: add `"SECRET_KEY": "test-secret-key"` to the local fixture's
config dict. Minimum surgery — same `:memory:` SQLite, same TESTING
flag. Model code and the safety guard are both correct; only the test
fixture was stale.
Test plan
- pytest tests/test_project_costs.py
- All 23 tests (TestProjectCostModel, TestProjectCostConstraints,
TestProjectCostMethods, TestProjectCostQueries, TestProjectCostIntegration)
should reach their assertions instead of erroring at fixture setup
### 1. has_permission() loses legacy-admin bypass after auto-assign (real bug)
`User.has_permission()` calls `_auto_assign_role_from_legacy()` which
side-effects: it queries the seeded "admin" Role and appends it to
`self.roles`. After the call, `self.roles` is no longer empty — so the
backward-compat bypass `if self.role == "admin" and not self.roles`
never fires for a legacy admin user. If the seeded admin Role has no
explicit permission rows assigned (as in tests), `has_permission()`
then returns False even though the user is documented to have all
permissions.
`test_legacy_admin_user_permissions` exercises exactly this and fails
with `assert admin.has_permission("any_permission") -> False`.
Fix: move the legacy-admin bypass check BEFORE the side-effecting
auto-assign call.
### 2. test_admin_role_user duplicates the seeded "admin" Role
`tests/conftest.py:334-337` seeds baseline Role rows ("admin", "user",
"manager", "subcontractor"). `test_admin_role_user` then creates
`Role(name="admin")` and commits, tripping the unique constraint on
roles.name with `IntegrityError`.
Fix: get-or-create. Re-use the existing seeded admin Role if present.
### 3. Stale assertion: expense amount returned as float, not string
`tests/test_routes/test_api_v1_expenses_complete.py:81` asserts
`data["expense"]["amount"] == "250.75"`, but `Expense.to_dict()` at
app/models/expense.py:189 returns `float(self.amount)`. The string
assertion never matches. Same pattern as the mileage `distance_km`
issue fixed in a sibling PR.
Test plan
- pytest tests/test_permissions.py::test_legacy_admin_user_permissions
- pytest tests/test_permissions.py::test_admin_role_user
- pytest tests/test_routes/test_api_v1_expenses_complete.py::TestAPIExpensesComplete::test_create_expense_all_fields
Two narrow bugs in tests/test_routes/test_api_v1_*_refactored.py that
have been failing on every CI run.
### 1. Fixture name collision: 'client' is the Flask test client
Four test methods declared `client` as a fixture parameter:
def test_create_project_uses_service_layer(self, app, client_with_token, client):
...
json={..., "client_id": client.id, ...}
The conftest.py `client` fixture returns the Flask test client
(`FlaskClient`), not a Client model row, so `client.id` raised
`AttributeError: 'FlaskClient' object has no attribute 'id'`. The Client
model row is exposed by conftest as `test_client`. Rename the parameter
(and the `.id` references) in:
- test_create_project_uses_service_layer
- test_list_projects_with_filters
- test_create_quote_uses_service_layer
- test_create_invoice_uses_service_layer
### 2. distance_km is returned as a float, not a string
`test_create_mileage` asserts:
assert data["mileage"]["distance_km"] == "50.5"
but `Mileage.to_dict()` returns `float(self.distance_km)` (see
app/models/mileage.py:192). The string assertion never matches. Fix the
test to compare to the float 50.5.
Test plan
- pytest tests/test_routes/test_api_v1_projects_refactored.py
- pytest tests/test_routes/test_api_v1_quotes_refactored.py
- pytest tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py::TestAPIInvoicesRefactored::test_create_invoice_uses_service_layer
- pytest tests/test_routes/test_api_v1_mileage_refactored.py::TestAPIMileageRefactored::test_create_mileage
`test_oidc_callback_does_not_store_id_token_in_cookie_session` has been
failing on every CI run with:
AssertionError: assert 'oidc_id_token_key' in
<SecureCookieSession {'_flashes': [('error',
'User account does not exist and self-registration is disabled.')]}>
The OIDC callback flow needs to (a) find or create a User matching the
incoming token's identity, then (b) store the id_token server-side and
put only a short key in the session. The test exercises path (b), but
in the test environment `ALLOW_SELF_REGISTER` is false (the production-
safe default in `app/config.py:45`), so step (a) flashes the
"self-registration disabled" error and short-circuits with a redirect
to /login before the token-storage code under test even runs.
Fix: pre-create a `User` row matching the fake OIDC subject's username
+ email so step (a) finds the user and step (b) executes. The test is
about session bloat, not the user-creation path; making it independent
of the `ALLOW_SELF_REGISTER` setting keeps it focused.
Test plan
- pytest tests/test_oidc_session_cookie_bloat.py::test_oidc_callback_does_not_store_id_token_in_cookie_session
20 test files share an anti-pattern: each declares its own local `app`
fixture with a hardcoded SQLite filename in the test runner's CWD,
e.g.
"SQLALCHEMY_DATABASE_URI": "sqlite:///test_api_kanban.sqlite",
Two problems:
1. If a prior test run crashed before teardown, the .sqlite file
persists. The next run starts on a non-empty database and trips
over FK / uniqueness constraints — `test_api_tax_currency_flow`
surfaces this as a 409 Conflict when its USD currency row already
exists.
2. `test_api_invoice_templates.sqlite` is used by two files
(`test_api_invoice_templates_v1.py` and
`test_api_invoice_templates_api_v1.py`). Under pytest-xdist they
race for the same file and corrupt each other's state.
Fix: each fixture now generates a unique per-call path using the same
pattern already established in `tests/conftest.py:204`:
unique_db_path = os.path.join(
tempfile.gettempdir(), f"test_<slug>_{uuid.uuid4().hex}.sqlite"
)
"SQLALCHEMY_DATABASE_URI": f"sqlite:///{unique_db_path}",
Adds `import os`, `import tempfile`, `import uuid` where missing.
Test lifecycle is unchanged — the existing fixture teardown
(`db.drop_all()` + engine dispose) still runs; the OS later reaps the
temp file. Black-formatted to the project's 120-char line limit.
Test plan
- pytest tests/test_api_kanban_v1.py
- pytest tests/test_api_tax_currency_v1.py (no leftover-USD 409)
- pytest -p xdist -n 2 tests/test_api_invoice_templates_v1.py tests/test_api_invoice_templates_api_v1.py (no collision)
The Expense model has a `notes` Text column and `Expense.to_dict()`
returns it, but both the PATCH route handler and the
`ExpenseService.update_expense` allowlist tuples omit `notes`. As a
result, callers can send `{"notes": "..."}` in a PUT/PATCH body and
get a 200 back with the field silently dropped — exactly what
`tests/test_api_expenses_v1.py::test_expenses_crud` asserts against
when it sends `notes="airport ride"` and reads back `None`.
Add `"notes"` to the two allowlist tuples (one in the route, one in
the service). No model or schema change required.
Test plan
- pytest tests/test_api_expenses_v1.py::test_expenses_crud
- pytest tests/test_routes/test_api_v1_expenses_complete.py
Four integration tests fell behind code changes in app/integrations/ and
app/models. They were asserting against the old data shapes / patching
the old mock targets and only surfaced now that v5.5.6's fixture refresh
re-enabled them.
### tests/test_integration/test_caldav_integration.py
test_sync_data_imports_events still expected the connector to write
TimeEntry rows and IntegrationExternalEventLink rows. The connector
in app/integrations/caldav_calendar.py:803 now writes CalendarEvent
rows and tracks imports via a [CalDAV: <uid>] marker embedded in the
description. Update the assertions to query CalendarEvent and look for
the marker. Add CalendarEvent to the model imports.
### tests/test_integration/test_activitywatch_integration.py
Four tests in this file patched `app.integrations.activitywatch.requests.get`.
The connector at app/integrations/activitywatch.py:78-79 was refactored
to route HTTP through `integration_session()` + `session_request()`,
so requests.get is never called and the mocks never fire. Tests that
relied on the mock then attempted real HTTP to localhost:5600 and
failed.
Repoint all four @patch decorators to
`app.integrations.activitywatch.session_request`. Update the one
positional-argument assertion in test_test_connection_success — the
URL moves from arg[0] to arg[2] because session_request's signature
is (session, method, url, **kw).
### tests/test_custom_field_definitions.py
test_delete_custom_field_preserves_other_fields called
`test_client.set_custom_field(...)` directly on the fixture instance
which may belong to a different SQLAlchemy session by the time the
test runs. The two JSON mutations weren't reliably persisted, so only
one of the two fields survived to the assertions. Re-query the client
via `Client.query.get(test_client.id)` before mutating — matches the
pattern already used in the surrounding tests in the same file.
Test plan
- pytest tests/test_integration/test_caldav_integration.py::TestCalDAVConnector::test_sync_data_imports_events
- pytest tests/test_integration/test_activitywatch_integration.py::TestActivityWatchConnector
- pytest tests/test_custom_field_definitions.py::test_delete_custom_field_preserves_other_fields
Five integration tests fail because three API v1 route groups call
`joinedload()` on relationships the underlying models never define,
producing AttributeError -> 500 on the first query.
The three bad references were all introduced in commit 579fc7af
("extract business logic to service layer", Nov 2025) and have been
quietly broken since. They surfaced only now because the v5.5.6
fixture refresh started exercising these endpoints more thoroughly.
FK enforcement (also added in v5.5.6) is a red herring here.
### Repairs
- `KanbanColumn` has no `project` relationship (only the `project_id`
FK column), and `KanbanColumn.to_dict()` never reads `self.project`.
The eager load is dead code in both the PUT/PATCH and DELETE
handlers — drop it.
- `ClientNote` has an `author` relationship, not `created_by_user`,
and `ClientNote.to_dict()` reads `self.author.username` /
`self.author.full_name`. The four call sites still need eager
loading; rename the attribute to the real one (`author`).
- `SavedFilter` has no `user` relationship (only the `user_id` FK
column), and `SavedFilter.to_dict()` never reads `self.user`. Like
KanbanColumn, the eager load is dead code in all four call sites —
drop it.
### Tests fixed
- tests/test_api_kanban_v1.py::test_kanban_columns
- tests/test_api_client_notes_v1.py::test_client_notes_crud
- tests/test_api_saved_filters_v1.py::test_saved_filters_crud
- tests/test_routes/test_api_v1_invoices_tasks_expenses_refactored.py::TestAPITasksRefactored::test_get_task_uses_eager_loading (transitively, via the same route module)
### Notes
The unused `from sqlalchemy.orm import joinedload` import in the
kanban/saved-filter handlers is left in place to keep the diff
surgical. It can be removed in a follow-up cleanup.
### Test plan
- [ ] pytest tests/test_api_kanban_v1.py::test_kanban_columns
- [ ] pytest tests/test_api_client_notes_v1.py::test_client_notes_crud
- [ ] pytest tests/test_api_saved_filters_v1.py::test_saved_filters_crud
- [ ] All three return 200 from CRUD operations; no AttributeError logged
- [ ] No regression on routes that already used joinedload correctly
Three tests in `test_timer_edit_own_time_entries.py` failed with
`sqlalchemy.exc.InvalidRequestError: Object '<Role at 0x...>' is not
attached to a Session` when the suite ran after v5.5.6's fixture
refresh.
Root cause: the `user` fixture commits the user and calls
`db.session.refresh(user)`, leaving the instance bound to the
fixture's scoped session. Each test then enters a fresh
`with app.app_context():` block before calling
`_ensure_edit_own_permission(user)`. Flask-SQLAlchemy's scoped session
in the new context is a *different* session than the one that owns
`user`, so when `user.add_role(role)` tries to associate a freshly
loaded `Role` with the cross-session `user`, the resulting commit
detaches the role and the next attribute access fails.
Fix: have `_ensure_edit_own_permission` take a `user_id` instead of the
ORM instance and re-query the user from the active session
(`User.query.get(user_id)`). All three objects (user, role, permission)
now live in the same identity map. Drop the trailing
`db.session.refresh(user)` — route handlers re-load the user via their
own request-scoped sessions and see the persisted association.
Test plan
- pytest tests/test_timer_edit_own_time_entries.py
Bundles four narrow fixes surfaced by the comprehensive CI run after
v5.5.6 enabled SQLite FK enforcement and refreshed test fixtures. Each
fix is independent; bundled here only to keep PR overhead low.
1. tests/test_routes/test_api_v1_expenses_complete.py
- Add missing `User` import. `test_expense_permissions` references
`User.query.filter(...)` but `User` was never imported, causing a
NameError at test runtime.
2. tests/test_client_single_simplification.py
- `Client(...status="active")` failed with TypeError because the
Client model's __init__ does not accept `status` as a keyword
(column default already sets it to "active"). Set the attribute
after construction.
3. tests/test_utils/test_api_auth_enhanced.py
- `User(username=..., is_active=True)` failed because User.__init__
does not accept `is_active`. Set the attribute after construction.
- 9 occurrences of `app.test_request_context(remote_addr="...")`
failed with `TypeError: EnvironBuilder.__init__() got an unexpected
keyword argument 'remote_addr'`. Werkzeug removed the keyword;
replace with `environ_overrides={"REMOTE_ADDR": "..."}` which is
the supported equivalent.
4. app/routes/api_v1_time_entries.py
- DELETE /api/v1/time-entries/<id> returned 415 Unsupported Media
Type when called without an `application/json` Content-Type, even
though the body is optional (only used to capture an audit reason).
Switch to `request.get_json(silent=True)` so the endpoint accepts
DELETE requests with no body. Same change applied to no other
methods; POST/PUT continue to require explicit JSON.
The route file also picked up a black/isort pass from the project
auto-formatter; behaviour is identical to before, only whitespace and
import grouping differ.
Test plan
- pytest tests/test_routes/test_api_v1_expenses_complete.py::TestAPIExpensesComplete::test_expense_permissions
- pytest tests/test_client_single_simplification.py::test_manual_entry_shows_select_when_multiple_clients
- pytest tests/test_utils/test_api_auth_enhanced.py::TestAuthenticateToken
- pytest tests/test_routes/test_api_v1_time_entries_complete.py::TestAPITimeEntriesComplete::test_delete_time_entry_uses_service_layer
Bump setup.py to 5.5.7 and document the invoice PDF designer layout fix,
designer-to-ReportLab template parity, and preview vs export alignment
for issue #622 in CHANGELOG.md.
Close the missing canvas-area wrapper in the invoice PDF designer so the properties panel sits in the third grid column beside the canvas instead of below it.
When saving template JSON from the designer, read items-table and expenses-table width, colors, and separator line settings from the Konva group children so exports match user edits. Scale column widths to the chosen table width and emit a style block for ReportLab.
In the ReportLab renderer, scale column widths to element.width, wrap tables in a two-column outer table to honor horizontal x offset relative to the left margin, and apply borderColor and borderWidth from template style.
Extend the JSON-to-HTML preview converter so table header text, row text, row background, and border width reflect the same style keys used on export (fixes preview vs PDF mismatch for issue #622).
The test file uses pytestmark = [pytest.mark.integration] on line 5 but
does not import pytest, causing pytest collection to fail with
NameError: name 'pytest' is not defined. Add the missing import.
- 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.
The dashboard route now writes two cache entries per user
(dashboard:stats:<id> and dashboard💹<id>) instead of a single
dashboard:<id> blob, and today_hours moved into the nested
time_tracking payload. Update the caching tests to mirror this shape
and centralise the key tuple in a small helper.
The ProjectCost.updated_at column default captures datetime.utcnow at
class definition time, so patching app.models.project_cost.datetime
does not change the initial value written on insert. Assign
updated_at directly on the instance to get the deterministic
"before" timestamp the test relies on.
base.html now loads keyboard-shortcuts-advanced.js globally instead of
keyboard-shortcuts-enhanced.js, and the cheat-sheet CSS is only pulled
in on the dedicated settings page. Update the integration assertion
accordingly.
- Return lightweight SimpleNamespace snapshots from the sample_client
and sample_project fixtures so downstream tests can access .id and
.name without re-attaching the SQLAlchemy instance to a new session.
- In test_settings_currency_can_be_changed, persist the Settings row
when get_settings() returns a transient instance, and re-query for
verification instead of refresh() (which fails on transient objects).
WebhookDelivery.next_retry_at is a naive DateTime column. Strip the
tzinfo before passing it to mark_retrying() so the round-trip
comparison after commit/reload does not fail with a naive/aware
mismatch.
Supplier.stock_items is no longer a list-like relationship; assert
against the dynamic supplier_items relationship using .count() and
.first() instead.
With foreign keys enforced in tests, hard-coded client_id=1 and
user_id=1 references no longer work because no such rows exist in the
isolated test database. Create the required parent rows (Client for
inventory project tests, Client + User for the burndown/SavedFilter
smoke tests) before inserting the children.