Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .github/workflows/migration-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,24 @@ jobs:
- name: Install dependencies
run: pip install -r requirements.txt

- name: Ensure single Alembic head
env:
FLASK_APP: app.py
FLASK_ENV: testing
DATABASE_URL: postgresql://test_user:test_password@localhost:5432/test_db
run: |
set -e
echo "🔍 Checking for a single Alembic migration head..."
HEADS=$(flask db heads 2>/dev/null | sed '/^$/d' | wc -l)
echo "Alembic heads found: $HEADS"
flask db heads || true
if [ "$HEADS" -ne 1 ]; then
echo "❌ Expected exactly one Alembic head, found $HEADS"
echo "Create a merge migration before merging this PR."
exit 1
fi
echo "✅ Single Alembic head confirmed"

- name: Check for migration changes
id: migration_check
run: |
Expand Down
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.17.0] - 2026-09-23

### Added

- **Factur-X / ZUGFeRD compliance (Discussion #433)** — Structured company and client addresses (street, postcode, city, country), IBAN/BIC, default and per-invoice VAT category (S/Z/E/AE/K/G/O) with AT/DE Kleinunternehmer and reverse-charge presets (migration **196**). Invoice PDFs embed Liberation TrueType fonts for PDF/A font compliance. Single-pass Factur-X embed + PDF/A-3b (catalog `/AF`, pdfaExtension XMP, OutputIntent). CII XML fixes for EN 16931 (element order, currencyID only on TaxTotalAmount, correct VAT exemptions, Peppol endpoint as URIID, payment means, prepaid amounts). Pre-export validation blocks missing seller/buyer country or AE without buyer VAT ID.
- **Idle unanswered action (#722)** — Admin setting `idle_unanswered_action` (`review` default, or `auto_stop`) controls what happens when the "Still working?" grace window expires unanswered. Auto-stop credits last activity plus the idle timeout across the server sweep, web, browser extension, desktop, and mobile clients.
- **Phase 4 (gap roadmap)** — GDPR user erasure (`UserGdprService`, `POST /api/v1/users/me/erase`, admin `POST /admin/users/<id>/erase`, `POST /api/erase-account`); API v1 list/get for weekly goals, recurring tasks, and project templates; estimates vs actuals report (HTML + JSON); two-step timesheet period approval when multi-level policy is enabled; experimental XRechnung XML helper; Mollie payment provider skeleton; admin module quick presets (Solo focus); custom field definitions `entity_type` for client/project/task/time entry (migration **197**).
- **Phase 5 enterprise foundations** — Design docs under `docs/design/` (OAuth 2.0 API apps, SAML/SCIM, multi-tenant, Zapier/Make webhooks, Teams bot parity). Scaffolding: OAuth application models (migration **198**), optional SCIM `GET /scim/v2/Users` stub (`SCIM_ENABLED`), `POST /api/v1/ai/summarize-entries`, Teams bot command stub.

### Changed

- **Security / hygiene** — Legacy integration webhook CSRF-exempt; default `RATELIMIT_DEFAULT`; rate limits on version check/dismiss; hot-path silent `except`/`pass` replaced with logged handling; Ruff S110 enabled; admin API tokens/backups and API v1 audit-logs extracted from god files.
- **UI consistency** — Bootstrap leftover class cleanup; shared `ttConfirm`/`ttAlert` dialogs; empty states on major list pages; expanded command palette; client versions aligned to **5.17.0**.
- **Dead code** — Removed unregistered `timer_refactored.py`, `invoices_refactored.py`, `projects_refactored_example.py`, `offers.py`.

### Documentation

- **Factur-X / ZUGFeRD** — [PEPPOL_EINVOICING.md](docs/admin/configuration/PEPPOL_EINVOICING.md) updated for structured addresses, VAT categories, embedded fonts, single-pass PDF/A-3, and migration **196**.
- **Gap roadmap** — `docs/GAP_ROADMAP.md` tracks Phases 0–5 remediation status.
- **Version** — Bumped `setup.py` to **5.17.0** (single source of truth for the application version).

## [5.16.0] - 2026-09-18

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh

**Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history.

### ✨ Highlights of v5.17.0

**Minor (5.17.0):** **Factur-X / ZUGFeRD (#433)** — structured addresses, VAT categories, PDF/A-3 embed, and EN 16931 CII fixes. **Idle unanswered action (#722)** — admin choice of review vs auto-stop when Still working? expires. **Phase 4 gap roadmap** — GDPR erasure, weekly goals / recurring tasks / project templates API, estimates vs actuals report, multi-level timesheet approval, XRechnung helper, Mollie skeleton. **Phase 5 foundations** — OAuth app models, SCIM Users stub, AI summarize-entries, Teams bot stub, and design docs. **UI / hygiene** — shared confirm dialogs, empty states, command palette expansion, security rate limits, and dead-code cleanup. See [CHANGELOG.md](CHANGELOG.md#5170---2026-09-23).

### ✨ Highlights of v5.16.0

**Minor (5.16.0):** **Client–team messaging** — bidirectional in-portal messaging between team and client contacts with thread UI on both sides. **Gmail & Outlook sync** — email threads pulled from Gmail API and Microsoft Graph and linked to CRM clients, leads, and deals. **Payroll sync (Gusto & ADP)** — time entries aggregated into payroll batches and pushed to Gusto and ADP Workforce Now. **DATEV export** — EXTF Buchungsstapel CSV generator for direct DATEV import. **Sage integration** — invoices, contacts, and payments synced with Sage Business Cloud. **Integration wizards** — guided setup wizards for ADP, DATEV, Gmail, Gusto, Outlook Email, and Sage. **Visual workflow builder** — drag-and-drop canvas for building automation workflows. **Portal custom domains** — white-label client portal host resolution. **Client Portal API** — new authenticated REST blueprint for portal sessions and data access. See [CHANGELOG.md](CHANGELOG.md#5160---2026-09-18).
Expand Down
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
5.17.0
64 changes: 32 additions & 32 deletions app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def log_event(name: str, **kwargs):
if is_otel_tracing_active():
extra.update(get_trace_context_for_logs())
except Exception:
pass
logging.getLogger(__name__).debug("Could not attach trace context to log_event", exc_info=True)
json_logger.info(name, extra=extra)
except Exception as e:
logging.getLogger(__name__).debug("Structured log_event failed: %s", e)
Expand Down Expand Up @@ -113,7 +113,7 @@ def track_event(user_id, event_name, properties=None):

send_analytics_event(user_id, event_name, properties)
except Exception:
pass
logging.getLogger(__name__).debug("Telemetry track_event failed", exc_info=True)


def track_page_view(page_name, user_id=None, properties=None):
Expand Down Expand Up @@ -212,7 +212,7 @@ def create_app(config=None):
try:
os.environ.pop(var, None)
except Exception:
pass
logger.debug("Could not unset env var during test setup", exc_info=True)
db_uri = str(app.config.get("SQLALCHEMY_DATABASE_URI", "") or "")
if (
app.config.get("TESTING")
Expand All @@ -234,7 +234,7 @@ def create_app(config=None):
app.config["SQLALCHEMY_SESSION_OPTIONS"] = session_opts
except Exception:
# Do not fail app creation for engine option tweaks
pass
logger.debug("Test SQLite engine/session option tweaks failed", exc_info=True)

# All templates live in app/templates (legacy root templates/ was merged in)

Expand Down Expand Up @@ -285,7 +285,7 @@ def create_app(config=None):
# Ensure all model tables are registered in SQLAlchemy metadata
from . import models as _models # noqa: F401
except Exception:
pass
logger.debug("Could not import models during migrate bootstrap", exc_info=True)
return app

# Initialize audit logging - register event listeners AFTER db.init_app()
Expand Down Expand Up @@ -336,7 +336,7 @@ def _listen_once(target, identifier, fn):
_listen_once(SignallingSession, "after_flush", audit.receive_after_flush)
logger.info("Registered audit logging with Flask-SQLAlchemy SignallingSession")
except (ImportError, AttributeError):
pass
logger.debug("SignallingSession audit registration skipped", exc_info=True)

logger.info("Audit logging event listeners registered")

Expand Down Expand Up @@ -384,7 +384,7 @@ def _listen_once(target, identifier, fn):

send_base_first_seen()
except Exception:
pass
logger.debug("send_base_first_seen failed", exc_info=True)

# Only initialize CSRF protection if enabled
if app.config.get("WTF_CSRF_ENABLED"):
Expand Down Expand Up @@ -424,7 +424,7 @@ def _listen_once(target, identifier, fn):
for d in abs_dirs:
ensure_translations_compiled(d)
except Exception:
pass
logger.debug("Babel translation directory setup failed", exc_info=True)

# Internationalization: locale selector compatible with Flask-Babel v4+
def _select_locale():
Expand Down Expand Up @@ -482,7 +482,7 @@ def _normalize_locale(locale_code):

app.jinja_env.globals.update(_=_gettext, ngettext=_ngettext)
except Exception:
pass
logger.debug("Could not register gettext in Jinja globals", exc_info=True)

# Add Python built-ins that are useful in templates
app.jinja_env.globals.update(getattr=getattr)
Expand Down Expand Up @@ -541,7 +541,7 @@ def _resolve_portal_custom_domain():

return redirect(url_for("client_portal.login"))
except Exception:
pass
app.logger.debug("Portal custom domain root redirect failed", exc_info=True)

# Remember the public base URL from real requests so background jobs can build
# absolute links without SERVER_NAME (see app.utils.urls).
Expand All @@ -552,7 +552,7 @@ def _remember_app_base_url():

remember_request_base_url()
except Exception:
pass
app.logger.debug("remember_request_base_url failed", exc_info=True)

# Registered as a Jinja *global*, not a context processor: macro files such as
# components/multi_select.html contain inline <script> blocks and are imported with
Expand All @@ -578,7 +578,7 @@ def _harmonize_login_session_keys():
session["user_id"] = uid_str
except Exception:
# Do not block request processing on any session edge case
pass
app.logger.debug("Session login key harmonization failed", exc_info=True)

# In testing, ensure that if a session user id is present but current_user
# isn't populated yet, we proactively authenticate the user for this request.
Expand Down Expand Up @@ -608,7 +608,7 @@ def _ensure_user_authenticated_in_tests():
login_user(user, remember=True)
except Exception:
# Never fail the request due to this helper
pass
app.logger.debug("Test auth bootstrap helper failed", exc_info=True)

# Register user loader
@login_manager.user_loader
Expand Down Expand Up @@ -662,7 +662,7 @@ def restrict_portal_only_users():

return redirect(url_for("client_portal.dashboard"))
except Exception:
pass
app.logger.debug("restrict_portal_only_users failed", exc_info=True)

@app.before_request
def restrict_native_client_portal_sessions():
Expand All @@ -676,7 +676,7 @@ def restrict_native_client_portal_sessions():
if should_redirect_native_client_portal_session():
return redirect_native_client_to_portal()
except Exception:
pass
app.logger.debug("restrict_native_client_portal_sessions failed", exc_info=True)

# Check if initial setup is required (skip for certain routes)
@app.before_request
Expand Down Expand Up @@ -716,15 +716,15 @@ def check_setup_required():
if not installation_config.is_setup_complete():
return redirect(url_for("setup.initial_setup"))
except Exception:
pass
app.logger.debug("check_setup_required failed", exc_info=True)

# Attach request ID for tracing
@app.before_request
def attach_request_id():
try:
g.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
except Exception:
pass
app.logger.debug("attach_request_id failed", exc_info=True)

@app.before_request
def handle_api_cors_preflight():
Expand All @@ -742,7 +742,7 @@ def prom_start_timer():
try:
g._start_time = time.time()
except Exception:
pass
app.logger.debug("prom_start_timer failed", exc_info=True)

# Request logging for /login to trace POSTs reaching the app
@app.before_request
Expand All @@ -757,7 +757,7 @@ def log_login_requests():
request.headers.get("User-Agent"),
)
except Exception:
pass
app.logger.debug("log_login_requests failed", exc_info=True)

# Record Prometheus metrics and log write operations
@app.after_request
Expand All @@ -769,7 +769,7 @@ def record_metrics_and_log(response):
REQUEST_LATENCY.labels(endpoint=endpoint).observe(latency)
REQUEST_COUNT.labels(method=request.method, endpoint=endpoint, http_status=response.status_code).inc()
except Exception:
pass
app.logger.debug("Prometheus request metrics recording failed", exc_info=True)

try:
from app.telemetry.otel_setup import inject_traceparent_headers, record_http_server_metrics
Expand All @@ -778,7 +778,7 @@ def record_metrics_and_log(response):
record_http_server_metrics(request.method, route, response.status_code, latency)
response = inject_traceparent_headers(response)
except Exception:
pass
app.logger.debug("OpenTelemetry HTTP metrics/trace headers failed", exc_info=True)

try:
# Log write operations
Expand All @@ -791,7 +791,7 @@ def record_metrics_and_log(response):
request.headers.get("X-Forwarded-For") or request.remote_addr,
)
except Exception:
pass
app.logger.debug("Write-operation request logging failed", exc_info=True)
return response

# Configure session
Expand Down Expand Up @@ -942,7 +942,7 @@ def apply_security_headers(response):
if not response.headers.get("Permissions-Policy"):
response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
except Exception:
pass
app.logger.debug("Security headers application failed", exc_info=True)

# CSRF cookie/token handling
# If CSRF is enabled, ensure CSRF cookie exists for HTML GET responses
Expand All @@ -954,7 +954,7 @@ def apply_security_headers(response):
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "Authorization, Content-Type, Accept, X-Request-ID"
except Exception:
pass
app.logger.debug("API CORS response headers failed", exc_info=True)

if app.config.get("WTF_CSRF_ENABLED"):
try:
Expand Down Expand Up @@ -997,7 +997,7 @@ def apply_security_headers(response):
path=cookie_path,
)
except Exception:
pass
app.logger.debug("CSRF cookie set on HTML GET failed", exc_info=True)
else:
try:
cookie_name = app.config.get("CSRF_COOKIE_NAME", "XSRF-TOKEN")
Expand All @@ -1017,7 +1017,7 @@ def apply_security_headers(response):
samesite=app.config.get("CSRF_COOKIE_SAMESITE", "Lax"),
)
except Exception:
pass
app.logger.debug("CSRF cookie clear failed", exc_info=True)
return response

# CSRF error handler with HTML-friendly fallback
Expand Down Expand Up @@ -1049,7 +1049,7 @@ def handle_csrf_error(e):
getattr(e, "description", ""),
)
except Exception:
pass
app.logger.debug("CSRF failure diagnostic logging failed", exc_info=True)

if request.method == "POST" and (is_classic_form or (request.form and not request.is_json)):
try:
Expand All @@ -1067,7 +1067,7 @@ def handle_csrf_error(e):
if ref_host and ref_host == cur_host:
dest = ref
except Exception:
pass
app.logger.debug("CSRF redirect referrer parse failed", exc_info=True)
return redirect(dest)

# JSON/XHR fall-through
Expand Down Expand Up @@ -1097,7 +1097,7 @@ def handle_csrf_error(e):
if ref_host and ref_host == cur_host:
dest = ref
except Exception:
pass
app.logger.debug("CSRF redirect referrer parse failed", exc_info=True)
return redirect(dest)

# Expose csrf_token() in Jinja templates even without FlaskForm
Expand Down Expand Up @@ -1136,7 +1136,7 @@ def get_csrf_token():
try:
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
except Exception:
pass
app.logger.debug("Could not set Cache-Control on csrf-token response", exc_info=True)
# Also set/update a CSRF cookie for double-submit pattern and SPA helpers
try:
cookie_name = app.config.get("CSRF_COOKIE_NAME", "XSRF-TOKEN")
Expand Down Expand Up @@ -1166,7 +1166,7 @@ def get_csrf_token():
path=cookie_path,
)
except Exception:
pass
app.logger.debug("Could not set CSRF cookie on csrf-token response", exc_info=True)
return resp

# Register blueprints (centralized in blueprint_registry)
Expand Down Expand Up @@ -1449,7 +1449,7 @@ def _promote_admin_users_on_request():
try:
db.session.rollback()
except Exception:
pass
app.logger.debug("Rollback after admin promotion failed", exc_info=True)

# Initialize database on first request
def initialize_database():
Expand Down
Loading
Loading