diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh index b709e67..44ee421 100644 --- a/deploy/entrypoint.sh +++ b/deploy/entrypoint.sh @@ -11,7 +11,7 @@ for dir in /app/data /app/config /app/repositories; do if [ -d "$dir" ]; then current_owner=$(stat -c '%u:%g' "$dir" 2>/dev/null || echo "") if [ -z "$current_owner" ] || [ "$current_owner" != "$APP_UID:$APP_GID" ]; then - chown -R app:app "$dir" 2>/dev/null || true + chown -R --no-dereference app:app "$dir" 2>/dev/null || true fi fi done diff --git a/simple_org_chart/settings.py b/simple_org_chart/settings.py index 2913637..3e5258c 100644 --- a/simple_org_chart/settings.py +++ b/simple_org_chart/settings.py @@ -9,7 +9,7 @@ import re import tempfile import threading -from typing import Any, Dict, Iterable, Set +from typing import Any, Callable, Dict, Iterable, Set, Union from .config import SETTINGS_FILE @@ -24,8 +24,21 @@ class _InterProcessSettingsFileLock: falls back gracefully to thread-only locking. """ - def __init__(self, lock_file_path: str) -> None: - self._lock_file_path = lock_file_path + def __init__( + self, + lock_file_path: Union[str, bytes, "os.PathLike[str]", Callable[[], Union[str, bytes, "os.PathLike[str]"]]], + ) -> None: + # Accept either a static path (str/bytes/Path) or a zero-argument + # callable that returns the path at acquire-time. The callable form + # lets callers that re-bind the module-level SETTINGS_FILE (e.g. in + # tests via monkeypatch) have the lock always protect the file that is + # actually being accessed rather than the path that was current at + # import time. + if callable(lock_file_path): + self._get_lock_file_path = lock_file_path + else: + _static = lock_file_path + self._get_lock_file_path = lambda: _static self._thread_lock = threading.Lock() self._fd: int | None = None @@ -36,7 +49,8 @@ def acquire(self, blocking: bool = True) -> bool: fd = None try: import fcntl - fd = os.open(self._lock_file_path, os.O_CREAT | os.O_RDWR, 0o600) + lock_file_path = self._get_lock_file_path() + fd = os.open(lock_file_path, os.O_CREAT | os.O_RDWR, 0o600) flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB) fcntl.flock(fd, flags) self._fd = fd @@ -85,11 +99,21 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: # Both settings.py and email_config.py import this lock so that concurrent # writes from different code paths (scheduler, HTTP handlers, workers) are # serialised across all Gunicorn worker processes. -_settings_lock_file = os.path.join( - os.fspath(SETTINGS_FILE.parent), - f"{SETTINGS_FILE.name}.lock", -) -_settings_file_lock = _InterProcessSettingsFileLock(_settings_lock_file) +# +# The factory callable reads SETTINGS_FILE from this module's namespace at +# acquire-time rather than computing the path once at import time. This +# allows test-time monkeypatching of ``simple_org_chart.settings.SETTINGS_FILE`` +# to take effect so the lock file is always co-located with the settings file +# actually being accessed. +def _settings_lock_file_factory() -> str: + # Accessing the module-level name SETTINGS_FILE here (rather than via + # globals()) is sufficient: Python resolves global names at call-time via + # LOAD_GLOBAL, so monkeypatching ``simple_org_chart.settings.SETTINGS_FILE`` + # in tests will be reflected when this factory is invoked. + return os.path.join(os.fspath(SETTINGS_FILE.parent), f"{SETTINGS_FILE.name}.lock") + + +_settings_file_lock = _InterProcessSettingsFileLock(_settings_lock_file_factory) logger = logging.getLogger(__name__) diff --git a/static/configure.js b/static/configure.js index 0c2172b..a22896a 100644 --- a/static/configure.js +++ b/static/configure.js @@ -2363,10 +2363,10 @@ async function _initUserScannerConfigUI(isEnabled) { const latest = await statusResp.json(); if (latest.installed) { if (installRow) installRow.style.display = 'none'; - const ver = latest.version || 'unknown'; + const ver = latest.version || resolveTranslation('configure.userScanner.status.unknownVersion', 'unknown'); statusEl.innerHTML = ''; const versionText = document.createElement('span'); - versionText.textContent = `Installed — v${ver}`; + versionText.textContent = resolveTranslation('configure.userScanner.status.installedPrefix', 'Installed — v') + ver; statusEl.appendChild(versionText); const sep = document.createTextNode(' · '); statusEl.appendChild(sep); @@ -2374,7 +2374,7 @@ async function _initUserScannerConfigUI(isEnabled) { repoLink.href = 'https://github.com/kaifcodec/user-scanner'; repoLink.target = '_blank'; repoLink.rel = 'noopener noreferrer'; - repoLink.textContent = 'GitHub repo ↗'; + repoLink.textContent = resolveTranslation('configure.userScanner.status.repoLink', 'GitHub repo ↗'); repoLink.style.fontSize = 'inherit'; statusEl.appendChild(repoLink); if (updateRow) updateRow.style.display = 'flex'; @@ -2395,10 +2395,10 @@ async function _initUserScannerConfigUI(isEnabled) { } } else { if (installRow) installRow.style.display = 'none'; - const ver = data.version || 'unknown'; + const ver = data.version || resolveTranslation('configure.userScanner.status.unknownVersion', 'unknown'); statusEl.innerHTML = ''; const versionText = document.createElement('span'); - versionText.textContent = `Installed — v${ver}`; + versionText.textContent = resolveTranslation('configure.userScanner.status.installedPrefix', 'Installed — v') + ver; statusEl.appendChild(versionText); const sep = document.createTextNode(' · '); statusEl.appendChild(sep); @@ -2406,7 +2406,7 @@ async function _initUserScannerConfigUI(isEnabled) { repoLink.href = 'https://github.com/kaifcodec/user-scanner'; repoLink.target = '_blank'; repoLink.rel = 'noopener noreferrer'; - repoLink.textContent = 'GitHub repo ↗'; + repoLink.textContent = resolveTranslation('configure.userScanner.status.repoLink', 'GitHub repo ↗'); repoLink.style.fontSize = 'inherit'; statusEl.appendChild(repoLink); if (updateRow) updateRow.style.display = 'flex'; @@ -2417,19 +2417,20 @@ async function _initUserScannerConfigUI(isEnabled) { if (checkBtn) { checkBtn.addEventListener('click', async () => { checkBtn.disabled = true; - if (updateStatusEl) updateStatusEl.textContent = 'Checking…'; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.checking', 'Checking…'); if (applyBtn) applyBtn.style.display = 'none'; try { const resp = await fetch(`${window.location.origin}/api/user-scanner/check-update`, { credentials: 'include' }); const info = await resp.json(); if (info.updateAvailable) { - if (updateStatusEl) updateStatusEl.textContent = `Update available: v${info.currentVersion} → v${info.latestVersion}`; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.availablePrefix', 'Update available: v') + info.currentVersion + resolveTranslation('configure.userScanner.update.availableArrow', ' → v') + info.latestVersion; if (applyBtn) applyBtn.style.display = ''; } else { - if (updateStatusEl) updateStatusEl.textContent = `Up to date (v${info.currentVersion || info.latestVersion || 'unknown'})`; + const ver = info.currentVersion || info.latestVersion || resolveTranslation('configure.userScanner.update.unknownVersion', 'unknown'); + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.upToDatePrefix', 'Up to date (v') + ver + resolveTranslation('configure.userScanner.update.upToDateSuffix', ')'); } } catch (err) { - if (updateStatusEl) updateStatusEl.textContent = 'Failed to check for updates.'; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.checkFailed', 'Failed to check for updates.'); } finally { checkBtn.disabled = false; } @@ -2440,7 +2441,7 @@ async function _initUserScannerConfigUI(isEnabled) { if (applyBtn) { applyBtn.addEventListener('click', async () => { applyBtn.disabled = true; - if (updateStatusEl) updateStatusEl.textContent = 'Updating…'; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.updating', 'Updating…'); try { const resp = await fetch(`${window.location.origin}/api/user-scanner/update`, { method: 'POST', @@ -2448,17 +2449,17 @@ async function _initUserScannerConfigUI(isEnabled) { }); const result = await resp.json(); if (result.success) { - if (updateStatusEl) updateStatusEl.textContent = `Updated to v${result.version}`; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.updatedPrefix', 'Updated to v') + result.version; if (statusEl) { const verSpan = statusEl.querySelector('span'); - if (verSpan) verSpan.textContent = `Installed — v${result.version}`; + if (verSpan) verSpan.textContent = resolveTranslation('configure.userScanner.status.installedPrefix', 'Installed — v') + result.version; } applyBtn.style.display = 'none'; } else { - if (updateStatusEl) updateStatusEl.textContent = result.error || 'Update failed.'; + if (updateStatusEl) updateStatusEl.textContent = result.error || resolveTranslation('configure.userScanner.update.failed', 'Update failed.'); } } catch (err) { - if (updateStatusEl) updateStatusEl.textContent = 'Update failed: ' + err.message; + if (updateStatusEl) updateStatusEl.textContent = resolveTranslation('configure.userScanner.update.failedPrefix', 'Update failed: ') + err.message; } finally { applyBtn.disabled = false; } diff --git a/static/locales/en-US.json b/static/locales/en-US.json index 3524ff1..dd0c561 100644 --- a/static/locales/en-US.json +++ b/static/locales/en-US.json @@ -214,6 +214,24 @@ "failed": "Installation failed.", "notInstalled": "Not installed — download to enable the scanner.", "downloadFailed": "Download failed: " + }, + "status": { + "installedPrefix": "Installed — v", + "repoLink": "GitHub repo ↗", + "unknownVersion": "unknown" + }, + "update": { + "checking": "Checking…", + "availablePrefix": "Update available: v", + "availableArrow": " → v", + "upToDatePrefix": "Up to date (v", + "upToDateSuffix": ")", + "unknownVersion": "unknown", + "checkFailed": "Failed to check for updates.", + "updating": "Updating…", + "updatedPrefix": "Updated to v", + "failed": "Update failed.", + "failedPrefix": "Update failed: " } }, "presence": { diff --git a/tests/test_email_schedule.py b/tests/test_email_schedule.py index 598a815..3706287 100644 --- a/tests/test_email_schedule.py +++ b/tests/test_email_schedule.py @@ -24,6 +24,7 @@ should_send_email_now, ) import simple_org_chart.email_config as _email_config_mod +import simple_org_chart.settings as _settings_mod # --------------------------------------------------------------------------- @@ -252,6 +253,7 @@ class TestSavePreservesLastSent: def test_last_sent_preserved_on_save(self, tmp_path, monkeypatch): config_file = tmp_path / "app_settings.json" monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) + monkeypatch.setattr(_settings_mod, "SETTINGS_FILE", config_file) # Simulate a previous email send initial = DEFAULT_EMAIL_CONFIG.copy() @@ -268,6 +270,7 @@ def test_last_sent_preserved_on_save(self, tmp_path, monkeypatch): def test_last_sent_overwritten_when_explicitly_provided(self, tmp_path, monkeypatch): config_file = tmp_path / "app_settings.json" monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) + monkeypatch.setattr(_settings_mod, "SETTINGS_FILE", config_file) initial = DEFAULT_EMAIL_CONFIG.copy() initial["lastSent"] = "2026-03-01T12:00:00+00:00" @@ -282,6 +285,7 @@ def test_last_sent_overwritten_when_explicitly_provided(self, tmp_path, monkeypa def test_last_sent_none_when_no_prior_config(self, tmp_path, monkeypatch): config_file = tmp_path / "app_settings.json" monkeypatch.setattr(_email_config_mod, "SETTINGS_FILE", config_file) + monkeypatch.setattr(_settings_mod, "SETTINGS_FILE", config_file) save_email_config({"enabled": True, "frequency": "weekly"})