diff --git a/docs/maintenance/README.md b/docs/maintenance/README.md new file mode 100644 index 0000000..dc2a5b1 --- /dev/null +++ b/docs/maintenance/README.md @@ -0,0 +1,379 @@ +# Gateway Maintenance Script Documentation + +## Overview + +The `maintenance.ps1` script performs routine maintenance tasks for the NHS Rubie Gateway installation. It provides automated operations for: + +* MWL database backup +* PACS database backup +* PACS storage archiving +* Service log rotation + +The script ensures that Gateway services are safely stopped before maintenance activities are performed and restarted afterwards. + +--- + +# Script Location + +```text +\current\scripts\powershell\maintenance.ps1 +``` + +Default installation path: + +```text +C:\Program Files\NHS\ManageBreastScreeningGateway +``` + +Scheduled tasks are configured in the Windows Task Scheduler via the `deploy.ps1` script. +The `deploy.ps1` script is located at: + +```text +\current\scripts\powershell\deploy.ps1 +``` + +--- + +# Parameters + +| Parameter | Description | Default | +| ----------------- | ------------------------------------------------------- | --------------------------------------------------- | +| `BaseInstallPath` | Root installation directory of the Gateway application. | `C:\Program Files\NHS\ManageBreastScreeningGateway` | +| `Action` | Maintenance operation to execute. | None | + +### Supported Actions + +| Action | Description | +| -------------------- | --------------------------------------------------------------------- | +| `BackupMWLDatabase` | Creates a backup of the MWL database. | +| `BackupPACSDatabase` | Creates a backup of the PACS database and archives stored PACS files. | +| `RotateLogs` | Rotates Gateway service log files. | + +### Example + +```powershell +.\maintenance.ps1 -Action BackupMWLDatabase +``` + +--- + +# Logging + +Maintenance activity is recorded in: + +```text +\logs\maintenance.log +``` + +Each log entry includes: + +* Timestamp +* Log level +* Message + +Example: + +```text +[2026-01-01 02:00:00.123] [INFO] Starting database backup... +``` + +### Log Levels + +| Level | Meaning | +| ------- | ------------------------------------- | +| INFO | Informational messages | +| SUCCESS | Successful completion of an operation | +| WARNING | Non-critical issue | +| ERROR | Operation failure | + +--- + +# Gateway Service Management + +The script automatically discovers services matching: + +```text +Gateway-* +``` + +Examples: + +```text +Gateway-MWL +Gateway-PACS +Gateway-Relay +Gateway-Listener +``` + +## Service Stop Process + +Before maintenance operations begin: + +1. Each running Gateway service is stopped. +2. Service state is monitored until it reaches `Stopped`. +3. If a service fails to stop within the configured timeout, an exception is raised and processing stops. + +## Service Start Process + +After maintenance operations complete: + +1. Each Gateway service is started. +2. Service state is monitored until it reaches `Running`. +3. Successful startup is logged. + +--- + +# Database Backup Operations + +## Common Backup Configuration + +The script sets the following environment variables before invoking the Python backup utility: + +| Variable | Value | +| ------------- | -------------------------------------- | +| `PYTHONPATH` | `\current\scripts\python` | +| `BACKUP_PATH` | `\data\backups` | +| `MAX_BACKUPS` | `5` | + +The backup process is executed using: + +```powershell +..\python\database.py +``` + +Only the most recent five backups are retained. + +--- + +## MWL Database Backup + +Action: + +```powershell +BackupMWLDatabase +``` + +Database configuration: + +| Setting | Value | +| ------------- | -------------------------------- | +| Database File | `\data\worklist.db` | +| Table Name | `worklist_items` | + +Process: + +1. Stop Gateway services. +2. Backup MWL database. +3. Restart Gateway services. + +--- + +## PACS Database Backup + +Action: + +```powershell +BackupPACSDatabase +``` + +Database configuration: + +| Setting | Value | +| ------------- | ---------------------------- | +| Database File | `\data\pacs.db` | +| Table Name | `stored_instances` | + +Process: + +1. Stop Gateway services. +2. Backup PACS database. +3. Archive PACS storage files. +4. Restart Gateway services. + +--- + +# PACS Archive Process + +After a successful PACS database backup: + +## Source Directory + +```text +\data\storage +``` + +## Archive Location + +```text +\data\storage.zip +``` + +## Process + +1. All files within the PACS storage directory are compressed into a ZIP archive. +2. Archive is written to `storage.zip`. +3. Original storage contents are deleted. +4. Archive operation is logged. + +### Important + +The archive operation removes all files from the PACS storage directory after successful compression. + +--- + +# Log Rotation + +Action: + +```powershell +RotateLogs +``` + +The script rotates log files for each Gateway service. + +Example: + +```text +Gateway-MWL.log +``` + +becomes: + +```text +Gateway-MWL.log.1 +``` + +Existing rotations are shifted: + +```text +Gateway-MWL.log.1 -> Gateway-MWL.log.2 +Gateway-MWL.log.2 -> Gateway-MWL.log.3 +... +Gateway-MWL.log.5 removed +``` + +## Retention Policy + +* Maximum retained log generations: 5 +* Oldest rotation is deleted before creating a new rotation + +Process: + +1. Stop Gateway services. +2. Rotate all service logs. +3. Restart Gateway services. + +--- + +# Scheduled Tasks + +The script registers three Windows Scheduled Tasks. + +## MWL Database Maintenance + +| Setting | Value | +| --------- | ------------------------- | +| Task Name | `Gateway-MWL-Maintenance` | +| Schedule | Weekly | +| Day | Sunday | +| Time | 02:00 | +| User | SYSTEM | +| Run Level | Highest | + +Executed command: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File maintenance.ps1 -Action BackupMWLDatabase +``` + +--- + +## PACS Database Maintenance + +| Setting | Value | +| --------- | -------------------------- | +| Task Name | `Gateway-PACS-Maintenance` | +| Schedule | Weekly | +| Day | Sunday | +| Time | 02:15 | +| User | SYSTEM | +| Run Level | Highest | + +Executed command: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File maintenance.ps1 -Action BackupPACSDatabase +``` + +--- + +## Log Rotation Maintenance + +| Setting | Value | +| --------- | -------------------------- | +| Task Name | `Gateway-Logs-Maintenance` | +| Schedule | Daily | +| Time | 02:30 | +| User | SYSTEM | +| Run Level | Highest | + +Executed command: + +```powershell +powershell.exe -ExecutionPolicy Bypass -File maintenance.ps1 -Action RotateLogs +``` + +--- + +# Failure Handling + +The script terminates when: + +* A Gateway service cannot be stopped within the configured timeout. +* The database backup utility returns a non-zero exit code. +* An unhandled PowerShell exception occurs. + +Errors are written to: + +```text +\logs\maintenance.log +``` + +and displayed on the console. + +--- + +# Maintenance Workflow Summary + +## MWL Backup + +```text +Stop Services + ↓ +Backup MWL Database + ↓ +Start Services +``` + +## PACS Backup + +```text +Stop Services + ↓ +Backup PACS Database + ↓ +Archive PACS Files + ↓ +Start Services +``` + +## Log Rotation + +```text +Stop Services + ↓ +Rotate Logs + ↓ +Start Services +``` + diff --git a/scripts/bash/package_release.sh b/scripts/bash/package_release.sh index 215f223..33d5868 100755 --- a/scripts/bash/package_release.sh +++ b/scripts/bash/package_release.sh @@ -61,7 +61,8 @@ echo "" # ── Validate required files ─────────────────────────────────────────────────── -REQUIRED_FILES=("src/" "sample_images/" "scripts/python/" "pyproject.toml" "uv.lock" "README.md" "LICENCE.md") +REQUIRED_FILES=("src/" "sample_images/" "scripts/python/database.py" \ + "scripts/powershell/maintenance.ps1" "pyproject.toml" "uv.lock" "README.md" "LICENCE.md") for item in "${REQUIRED_FILES[@]}"; do if [[ ! -e "${REPO_ROOT}/${item}" ]]; then diff --git a/scripts/bat/backup_database.bat b/scripts/bat/backup_database.bat deleted file mode 100644 index c848abc..0000000 --- a/scripts/bat/backup_database.bat +++ /dev/null @@ -1,6 +0,0 @@ -:: Backs up and resets the MWL worklist database. Intended to be run via Windows Task Scheduler. -:: Assumes the project venv is at .venv\ relative to the root directory (uv default). -:: Adjust the Python path below if the deployment creates the venv elsewhere. -@echo off -set rootdir=%~1 -"%rootdir%\.venv\Scripts\python.exe" -c'import scripts.python.database; scripts.python.database.reset_worklist_database()' diff --git a/scripts/bat/schtasks.bat b/scripts/bat/schtasks.bat deleted file mode 100644 index f426288..0000000 --- a/scripts/bat/schtasks.bat +++ /dev/null @@ -1,7 +0,0 @@ -:: Registers a daily scheduled task to back up and reset the MWL worklist database. -:: Default schedule: daily at 02:00. Adjust /st to change the time. -:: /f overwrites the task if it already exists. -@echo off -set thisdir=%~dp0 -set rootdir="%thisdir%..\.." -schtasks /create /tn MWLDailyReset /tr "%thisdir%backup_database.bat %rootdir%" /sc daily /st 02:00 /f diff --git a/scripts/powershell/deploy.ps1 b/scripts/powershell/deploy.ps1 index 45d2232..e6d588d 100644 --- a/scripts/powershell/deploy.ps1 +++ b/scripts/powershell/deploy.ps1 @@ -560,6 +560,53 @@ if (-not $cutoverFailed) { } } +# -- Maintenance schedule ------------------------------------------------------------- + +if (-not $cutoverFailed) { + $maintenanceScriptPath = Join-Path $BaseInstallPath "current\scripts\powershell\maintenance.ps1" + $backupMWLAction = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-ExecutionPolicy Bypass -File `"$maintenanceScriptPath`" -Action BackupMWLDatabase" + + $backupMWLTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 2AM + + Register-ScheduledTask ` + -TaskName "Gateway-MWL-Maintenance" ` + -Action $backupMWLAction ` + -Trigger $backupMWLTrigger ` + -User "SYSTEM" ` + -RunLevel Highest ` + -Force + + $backupPACSAction = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-ExecutionPolicy Bypass -File `"$maintenanceScriptPath`" -Action BackupPACSDatabase" + + $backupPACSTrigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At 2:15AM + + Register-ScheduledTask ` + -TaskName "Gateway-PACS-Maintenance" ` + -Action $backupPACSAction ` + -Trigger $backupPACSTrigger ` + -User "SYSTEM" ` + -RunLevel Highest ` + -Force + + $rotateLogsAction = New-ScheduledTaskAction ` + -Execute "powershell.exe" ` + -Argument "-ExecutionPolicy Bypass -File `"$maintenanceScriptPath`" -Action RotateLogs" + + $rotateLogsTrigger = New-ScheduledTaskTrigger -Daily -At 2:30AM + Register-ScheduledTask ` + -TaskName "Gateway-Logs-Maintenance" ` + -Action $rotateLogsAction ` + -Trigger $rotateLogsTrigger ` + -User "SYSTEM" ` + -RunLevel Highest ` + -Force + +} + # -- Rollback on Failure ------------------------------------------------------ if ($cutoverFailed) { diff --git a/scripts/powershell/maintenance.ps1 b/scripts/powershell/maintenance.ps1 new file mode 100644 index 0000000..086c9ca --- /dev/null +++ b/scripts/powershell/maintenance.ps1 @@ -0,0 +1,236 @@ +param( + [string]$BaseInstallPath = "C:\Program Files\NHS\ManageBreastScreeningGateway", + [Parameter(Mandatory)] + [string]$Action +) + +# -- Set common vars ------------------------------------------------------- + +$logsDir = Join-Path $BaseInstallPath "logs" +$maintenanceLogFile = Join-Path $logsDir "maintenance.log" + +if (-not (Test-Path $logsDir)) { + New-Item -Path $logsDir -ItemType Directory -Force | Out-Null +} + + + +$services = Get-Service | + Where-Object { $_.Name -like "Gateway-*" } | + ForEach-Object { + @{ Name = $_.Name } + } + +function Write-Log { + param([string]$Message, [string]$Level = "INFO") + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss.fff" + $logEntry = "[$timestamp] [$Level] $Message" + Add-Content -Path $maintenanceLogFile -Value $logEntry + switch ($Level) { + "ERROR" { Write-Host $logEntry -ForegroundColor Red } + "WARNING" { Write-Host $logEntry -ForegroundColor Yellow } + "SUCCESS" { Write-Host $logEntry -ForegroundColor Green } + default { Write-Host $logEntry -ForegroundColor Gray } + } +} + +# -- Service control helpers ------------------------------------------------------- + +function Stop-AllServices { + param([array]$Services, [int]$TimeoutSeconds) + foreach ($svc in $Services) { + $status = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if (-not $status -or $status.Status -eq 'Stopped') { continue } + + Write-Log "Stopping $($svc.Name) (timeout: ${TimeoutSeconds}s)..." "INFO" + Stop-Service -Name $svc.Name -Force + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + $current = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if (-not $current -or $current.Status -eq 'Stopped') { break } + Start-Sleep -Milliseconds 500 + } + $stopwatch.Stop() + + $finalStatus = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($finalStatus -and $finalStatus.Status -ne 'Stopped') { + throw "Service $($svc.Name) did not stop within ${TimeoutSeconds}s (state: $($finalStatus.Status))." + } + Write-Log "$($svc.Name) stopped in $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s." "INFO" + } +} + +function Start-AllServices { + param( + [array]$Services, [int]$TimeoutSeconds + ) + + foreach ($svc in $Services) { + Write-Log "Starting $($svc.Name)..." "INFO" + + Start-Service -Name $svc.Name + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($stopwatch.Elapsed.TotalSeconds -lt $TimeoutSeconds) { + $status = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if ($status -and $status.Status -eq 'Running') { break } + Start-Sleep -Milliseconds 500 + } + $stopwatch.Stop() + + $finalStatus = Get-Service -Name $svc.Name -ErrorAction SilentlyContinue + if (-not $finalStatus -or $finalStatus.Status -ne 'Running') { + throw "Service $($svc.Name) did not start within ${TimeoutSeconds}s (state: $($finalStatus.Status))." + } + + Write-Log "$($svc.Name) started in $([math]::Round($stopwatch.Elapsed.TotalSeconds, 1))s." "SUCCESS" + } +} + +# -- Database backup ---------------------------------------------------------- + +function Invoke-DatabaseBackup { + param( + [string]$BaseInstallPath, + [string]$DbServiceName + ) + + Write-Log "Starting database backup..." "INFO" + + Set-Item -Path env:PYTHONPATH -Value "$BaseInstallPath\current\scripts\python" + Set-Item -Path env:BACKUP_PATH -Value "$BaseInstallPath\data\backups" + Set-Item -Path env:MAX_BACKUPS -Value 5 + + if ($DbServiceName -eq "MWL") { + Set-Item -Path env:DB_PATH -Value "$BaseInstallPath\data\worklist.db" + Set-Item -Path env:TABLE_NAME -Value "worklist_items" + } + if ($DbServiceName -eq "PACS") { + Set-Item -Path env:DB_PATH -Value "$BaseInstallPath\data\pacs.db" + Set-Item -Path env:TABLE_NAME -Value "stored_instances" + } + $pythonExe = Join-Path $BaseInstallPath "current\.venv\Scripts\python.exe" + $databaseScript = Join-Path $BaseInstallPath "current\scripts\python\database.py" + & $pythonExe $databaseScript + + if ($LASTEXITCODE -ne 0) { + Write-Log "Database backup failed with exit code $LASTEXITCODE" "ERROR" + throw "Database backup failed with exit code $LASTEXITCODE" + } + + Write-Log "Database backup completed successfully." "SUCCESS" +} + +# -- Log Rotation ------------------------------------------------------------- + +function Rotate-LogFile { + param( + [Parameter(Mandatory)] + [string]$LogFile, + + [int]$RetainCount = 5 + ) + + if (-not (Test-Path $LogFile)) { + return + } + + # Remove oldest + $oldest = "$LogFile.$RetainCount" + if (Test-Path $oldest) { + Remove-Item $oldest -Force + } + + # Shift existing rotations + for ($i = $RetainCount - 1; $i -ge 1; $i--) { + $src = "$LogFile.$i" + $dst = "$LogFile." + ($i + 1) + + if (Test-Path $src) { + Move-Item $src $dst -Force + } + } + + Move-Item $LogFile "$LogFile.1" -Force +} + +function Rotate-ServiceLogs { + param( + [array]$Services, + [string]$LogsDir + ) + + foreach ($svc in $Services) { + $logFile = Join-Path $LogsDir "$($svc.Name).log" + Rotate-LogFile -LogFile $logFile -RetainCount 5 + } +} + +# -- PACS archiving ------------------------------------------------------------- + +function Archive-PACS { + param( + [string]$BaseInstallPath + ) + + Write-Log "Archiving PACS files..." "INFO" + + $storageDir = Join-Path $BaseInstallPath "data\storage" + $archiveZip = Join-Path $BaseInstallPath "data\storage.zip" + + if (-not (Test-Path $storageDir)) { + Write-Log "PACS storage directory not found: $storageDir" "WARNING" + return + } + + $pacsDir = Join-Path $storageDir "*" + $hasFiles = Get-ChildItem -Path $pacsDir -Force -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $hasFiles) { + Write-Log "No PACS files found to archive." "INFO" + return + } + + Compress-Archive -Path $pacsDir -DestinationPath $archiveZip -Force + + Write-Log "PACS files archived to $archiveZip." "INFO" + + Remove-Item $pacsDir -Recurse -Force + Write-Log "PACS files removed from storage directory." "INFO" + + Write-Log "PACS files archived successfully." "SUCCESS" +} + +# -- Main ---------------------------------------------------------------------- + +$startStopTimeoutSeconds = 30 + +switch ($Action) { + + "RotateLogs" { + Stop-AllServices -Services $Services -TimeoutSeconds $startStopTimeoutSeconds + Rotate-LogFile -LogFile $maintenanceLogFile -RetainCount 5 + Rotate-ServiceLogs -Services $services -LogsDir $logsDir + Start-AllServices -Services $Services -TimeoutSeconds $startStopTimeoutSeconds + } + + "BackupPACSDatabase" { + Stop-AllServices -Services $Services -TimeoutSeconds $startStopTimeoutSeconds + Invoke-DatabaseBackup -BaseInstallPath $BaseInstallPath -DbServiceName "PACS" + Archive-PACS -BaseInstallPath $BaseInstallPath + Start-AllServices -Services $Services -TimeoutSeconds $startStopTimeoutSeconds + } + + "BackupMWLDatabase" { + Stop-AllServices -Services $Services -TimeoutSeconds $startStopTimeoutSeconds + Invoke-DatabaseBackup -BaseInstallPath $BaseInstallPath -DbServiceName "MWL" + Start-AllServices -Services $Services -TimeoutSeconds $startStopTimeoutSeconds + } + + default { + Write-Log "Unknown action: $Action" "ERROR" + throw "Unknown action: $Action" + } +} + +exit 0 diff --git a/scripts/python/database.py b/scripts/python/database.py index 3fdd7f6..2e16d5c 100644 --- a/scripts/python/database.py +++ b/scripts/python/database.py @@ -1,101 +1,116 @@ import logging import os import sqlite3 -from datetime import datetime logging.basicConfig( level=os.getenv("LOG_LEVEL", "INFO").upper(), - format=os.getenv("LOG_FORMAT", "%(asctime)s - %(name)s - %(levelname)s - %(message)s"), + format=os.getenv( + "LOG_FORMAT", + "%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ), ) logger = logging.getLogger(__name__) -def backup_database(db_path: str, backup_dir: str) -> str: - """ - Backup a SQLite database to a timestamped file in backup_dir. +MAX_BACKUPS = int(os.getenv("MAX_BACKUPS", 5)) + - Returns the path of the backup file. +def backup_db_path(db_path: str, backup_dir: str) -> str: + """ + Returns the path of the newest backup file (.backup.0). """ os.makedirs(backup_dir, exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") db_filename = os.path.basename(db_path) - backup_path = os.path.join(backup_dir, f"{timestamp}.{db_filename}.backup") - with sqlite3.connect(db_path) as conn: - with sqlite3.connect(backup_path) as backup_conn: - conn.backup(backup_conn) - return backup_path + return os.path.join(backup_dir, f"{db_filename}.backup.0") -def backup_databases(): +def rotate_backups(backup_dir: str, db_filename: str) -> None: """ - Backup all configured databases. - - Environment variables: - PACS_DB_PATH: Path to the PACS SQLite database - MWL_DB_PATH: Path to the MWL SQLite database - BACKUP_PATH: Directory for backups (default: ./backups) + Keep exactly MAX_BACKUPS backups: + backup.0 (newest) + backup.1 + backup.2 + backup.3 + backup.4 (oldest) + + Before creating a new backup, rotate existing files upward. """ - pacs_db_path = os.getenv("PACS_DB_PATH") - mwl_db_path = os.getenv("MWL_DB_PATH") - backup_path = os.getenv("BACKUP_PATH", "./backups") - - if not pacs_db_path and not mwl_db_path: - logger.warning("No database paths configured (PACS_DB_PATH or MWL_DB_PATH), skipping backup") - return - - success = True - - if pacs_db_path: - try: - pacs_backup_path = backup_database(pacs_db_path, backup_path) - except Exception as e: - logging.error(f"PACS backup failed: {e}") - success = False - else: - logging.info("PACS_DB_PATH not set, skipping PACS backup") - - if mwl_db_path: - try: - mwl_backup_path = backup_database(mwl_db_path, backup_path) - except Exception as e: - logging.error(f"MWL backup failed: {e}") - success = False - else: - logging.info("MWL_DB_PATH not set, skipping MWL backup") - - if success: - logger.info("All database backups completed successfully. Backup files:") - if pacs_db_path: - logger.info(f" PACS backup: {pacs_backup_path}") - if mwl_db_path: - logger.info(f" MWL backup: {mwl_backup_path}") - - -def reset_worklist_database() -> int: + # Remove the oldest backup first + oldest = os.path.join( + backup_dir, + f"{db_filename}.backup.{MAX_BACKUPS - 1}", + ) + if os.path.exists(oldest): + os.remove(oldest) + logger.info("Deleted oldest backup: %s", oldest) + + # Rotate existing backups upward + for i in range(MAX_BACKUPS - 2, -1, -1): + src = os.path.join(backup_dir, f"{db_filename}.backup.{i}") + dst = os.path.join(backup_dir, f"{db_filename}.backup.{i + 1}") + + if os.path.exists(src): + os.rename(src, dst) + logger.info("Rotated backup: %s -> %s", src, dst) + + +def backup_and_reset() -> int: """ - Backs up and clears the MWL database.. + Backup and reset a SQLite database. - Environment variables: - MWL_DB_PATH: Path to the MWL SQLite database (default: /var/lib/pacs/worklist.db) - BACKUP_PATH: Directory for database backups (default: /var/lib/pacs/backups) + Returns the number of rows deleted. """ - mwl_db_path = os.getenv("MWL_DB_PATH", "/var/lib/pacs/worklist.db") - backup_path = os.getenv("BACKUP_PATH", "/var/lib/pacs/backups") + db_path = os.getenv("DB_PATH") + if not db_path: + logger.warning("DB_PATH not set, skipping database reset") + return 0 + + table_name = os.getenv("TABLE_NAME") + if table_name not in {"stored_instances", "worklist_items"}: + logger.warning( + "Invalid TABLE_NAME '%s' specified, skipping database reset", + table_name, + ) + return 0 + + backup_dir = os.getenv("BACKUP_PATH", "./backups") + db_filename = os.path.basename(db_path) + + # Rotate existing backups before creating the new one + rotate_backups(backup_dir, db_filename) + + backup_path = backup_db_path(db_path, backup_dir) + count = 0 try: - path = backup_database(mwl_db_path, backup_path) - logger.info(f"Backup complete: {path}") - except Exception as e: - logger.error(f"MWL backup failed: {e}", exc_info=True) + with sqlite3.connect(db_path) as conn: + with sqlite3.connect(backup_path) as backup_conn: + conn.backup(backup_conn) - try: - with sqlite3.connect(mwl_db_path) as conn: - cursor = conn.execute("DELETE FROM worklist_items") + logger.info("Database backup complete: %s", backup_path) + + cursor = conn.execute(f"DELETE FROM {table_name}") conn.commit() count = cursor.rowcount - logger.info(f"MWL reset complete: {count} items deleted") - except Exception as e: - logger.error(f"MWL clear failed: {e}", exc_info=True) - return count + logger.info( + "Database reset complete: %s items deleted from %s", + count, + table_name, + ) + + backup_conn.close() + conn.close() + + return count + + except Exception: + logger.exception("Database reset failed") + raise + + +if __name__ == "__main__": + deleted = backup_and_reset() + logger.info("Total rows deleted: %d", deleted) + exit(0) diff --git a/tests/scripts/test_database.py b/tests/scripts/test_database.py index 7751240..80d87f5 100644 --- a/tests/scripts/test_database.py +++ b/tests/scripts/test_database.py @@ -6,82 +6,164 @@ sys.path.append(f"{Path(__file__).parent.parent.parent}/scripts/python") -from database import backup_database, reset_worklist_database +from database import ( + backup_and_reset, +) @pytest.fixture -def worklist_db(tmp_dir, monkeypatch): - db_path = f"{tmp_dir}/test_worklist.db" - with sqlite3.connect(db_path) as conn: - conn.execute("CREATE TABLE worklist_items (accession_number TEXT PRIMARY KEY, patient_id TEXT)") - conn.execute("INSERT INTO worklist_items VALUES ('ACC001', 'P001')") - conn.execute("INSERT INTO worklist_items VALUES ('ACC002', 'P002')") - conn.commit() +def sqlite_db(tmp_path): + db_path = tmp_path / "test.db" + + conn = sqlite3.connect(db_path) + + conn.execute( + """ + CREATE TABLE stored_instances ( + id INTEGER PRIMARY KEY, + value TEXT + ) + """ + ) + + conn.executemany( + "INSERT INTO stored_instances(value) VALUES (?)", + [("a",), ("b",), ("c",)], + ) + + conn.commit() + conn.close() - monkeypatch.setenv("BACKUP_PATH", f"{tmp_dir}/backups") - monkeypatch.setenv("MWL_DB_PATH", db_path) return db_path -# Tests for backup_database -def test_backup_creates_file(tmp_dir): - """Backup creates file.""" - db_path = f"{tmp_dir}/test.db" - sqlite3.connect(db_path).close() +def row_count(db_path, table): + conn = sqlite3.connect(db_path) + count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + conn.close() + return count - backup_path = backup_database(db_path, f"{tmp_dir}/backups") - assert Path(backup_path).exists() +def test_backup_and_reset_creates_backup( + tmp_path, + sqlite_db, + monkeypatch, +): + """Backup database creates backup.""" + backup_dir = tmp_path / "backups" + monkeypatch.setenv("DB_PATH", str(sqlite_db)) + monkeypatch.setenv("TABLE_NAME", "stored_instances") + monkeypatch.setenv("BACKUP_PATH", str(backup_dir)) + monkeypatch.setenv("MAX_BACKUPS", "5") + + deleted = backup_and_reset() + + assert deleted == 3 + + backup_file = backup_dir / "test.db.backup.0" + assert backup_file.exists() + + # Original DB was cleared + assert row_count(sqlite_db, "stored_instances") == 0 + + # Backup still contains original rows + assert row_count(backup_file, "stored_instances") == 3 + + +def test_rotation_keeps_five_backups( + tmp_path, + sqlite_db, + monkeypatch, +): + """Only 5 database backups are kept and older ones are deleted.""" + backup_dir = tmp_path / "backups" + monkeypatch.setenv("DB_PATH", str(sqlite_db)) + monkeypatch.setenv("TABLE_NAME", "stored_instances") + monkeypatch.setenv("BACKUP_PATH", str(backup_dir)) + monkeypatch.setenv("MAX_BACKUPS", "5") + + # Generate 7 backups + for i in range(7): + conn = sqlite3.connect(sqlite_db) + conn.execute( + "INSERT INTO stored_instances(value) VALUES (?)", + (f"run-{i}",), + ) + conn.commit() + conn.close() + backup_and_reset() -def test_backup_returns_timestamped_path(tmp_dir): - """Backup returns timestamped path.""" - db_path = f"{tmp_dir}/test.db" - sqlite3.connect(db_path).close() + backups = sorted(backup_dir.glob("test.db.backup.*")) - backup_path = backup_database(db_path, f"{tmp_dir}/backups") + assert len(backups) == 5 - assert backup_path.endswith(".db.backup") + expected = { + "test.db.backup.0", + "test.db.backup.1", + "test.db.backup.2", + "test.db.backup.3", + "test.db.backup.4", + } + assert {p.name for p in backups} == expected -def test_backup_creates_backup_dir_if_missing(tmp_dir): - """Backup creates backup dir if missing.""" - db_path = f"{tmp_dir}/test.db" - sqlite3.connect(db_path).close() - backup_dir = f"{tmp_dir}/backups/nested" - backup_path = backup_database(db_path, backup_dir) +def test_newest_backup_is_backup_zero( + tmp_path, + sqlite_db, + monkeypatch, +): + """Newest backup is always backup.0.""" + backup_dir = tmp_path / "backups" - assert Path(backup_path).exists() + monkeypatch.setenv("DB_PATH", str(sqlite_db)) + monkeypatch.setenv("TABLE_NAME", "stored_instances") + monkeypatch.setenv("BACKUP_PATH", str(backup_dir)) + monkeypatch.setenv("MAX_BACKUPS", "5") + # First backup contains 3 rows + backup_and_reset() -def test_backup_database_creates_backup(worklist_db): - """Backup database creates backup.""" - backup_path = backup_database(worklist_db, str(Path(worklist_db).parent / "backups")) - assert Path(backup_path).exists() + # Add one row and create another backup + conn = sqlite3.connect(sqlite_db) + conn.execute("INSERT INTO stored_instances(value) VALUES ('new')") + conn.commit() + conn.close() + backup_and_reset() -# Tests for reset_worklist_database -def test_reset_worklist_database_deletes_all_rows(worklist_db): - """Reset worklist database deletes all rows.""" - reset_worklist_database() + newest = backup_dir / "test.db.backup.0" + previous = backup_dir / "test.db.backup.1" - with sqlite3.connect(worklist_db) as conn: - count = conn.execute("SELECT COUNT(*) FROM worklist_items").fetchone()[0] - assert count == 0 + assert row_count(newest, "stored_instances") == 1 + assert row_count(previous, "stored_instances") == 3 -def test_reset_worklist_database_returns_row_count(worklist_db): - """Reset worklist database returns row count.""" - assert reset_worklist_database() == 2 +def test_invalid_table_name_is_ignored( + tmp_path, + sqlite_db, + monkeypatch, +): + """Invalid table name is ignored.""" + backup_dir = tmp_path / "backups" + monkeypatch.setenv("DB_PATH", str(sqlite_db)) + monkeypatch.setenv("TABLE_NAME", "users") + monkeypatch.setenv("BACKUP_PATH", str(backup_dir)) -def test_reset_worklist_database_returns_zero_when_empty(tmp_dir, monkeypatch): - """Reset worklist database returns zero when empty.""" - db_path = f"{tmp_dir}/worklist.db" - with sqlite3.connect(db_path) as conn: - conn.execute("CREATE TABLE worklist_items (accession_number TEXT PRIMARY KEY)") - conn.commit() + result = backup_and_reset() + + assert result == 0 + assert not backup_dir.exists() + + +def test_missing_db_path_is_ignored( + monkeypatch, +): + """Missing DB_PATH is ignored.""" + monkeypatch.delenv("DB_PATH", raising=False) + + result = backup_and_reset() - monkeypatch.setenv("MWL_DB_PATH", db_path) - assert reset_worklist_database() == 0 + assert result == 0