feat: add setting for history retention period

This commit is contained in:
2025-10-29 16:42:18 +02:00
parent 0aec44ec51
commit 3f60c04853
8 changed files with 265 additions and 95 deletions

View File

@@ -65,6 +65,7 @@ class ApiController extends OCSController {
* @return DataResponse<Http::STATUS_OK, array{
* last_update: non-empty-string|null,
* interval: int,
* retention_days: int,
* }, array{}>
*
* 200: Data returned
@@ -77,9 +78,10 @@ class ApiController extends OCSController {
}
$interval = $this->config->getValueInt(AppInfo\Application::APP_ID, 'cron_interval', 24);
$retentionDays = $this->config->getValueInt(AppInfo\Application::APP_ID, 'retention_days', 30);
return new DataResponse(
['last_update' => $lastUpdate, 'interval' => $interval]
['last_update' => $lastUpdate, 'interval' => $interval, 'retention_days' => $retentionDays]
);
}
@@ -140,7 +142,7 @@ class ApiController extends OCSController {
/**
* Update auto currency settings
*
* @param array{interval: int} $data Data to update
* @param array{interval: int, retention_days?: int} $data Data to update
* @return DataResponse<Http::STATUS_OK, array{status:non-empty-string}, array{}>
*
* 200: Data returned
@@ -149,6 +151,16 @@ class ApiController extends OCSController {
public function updateSettings(mixed $data): DataResponse {
$interval = $data['interval'];
$this->config->setValueInt(AppInfo\Application::APP_ID, 'cron_interval', $interval);
if (isset($data['retention_days'])) {
$retentionDays = (int)$data['retention_days'];
// Ensure it's not negative (0 = no limit, >0 = days to keep)
if ($retentionDays < 0) {
$retentionDays = 0;
}
$this->config->setValueInt(AppInfo\Application::APP_ID, 'retention_days', $retentionDays);
}
return new DataResponse(
['status' => 'OK']
);

View File

@@ -20,7 +20,6 @@ class FetchCurrenciesJob extends TimedJob {
$this->logger = $logger;
$this->config = $config;
// Run once a day
$interval = $this->config->getValueInt(AppInfo\Application::APP_ID, 'cron_interval', 24);
$this->setInterval(3600 * $interval);
$this->setTimeSensitivity(\OCP\BackgroundJob\IJob::TIME_INSENSITIVE);

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\AutoCurrency\Cron;
use OCA\AutoCurrency\Service\RemoveOldHistoryService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\TimedJob;
use Psr\Log\LoggerInterface;
class RemoveOldHistoryTask extends TimedJob {
public function __construct(
ITimeFactory $time,
private LoggerInterface $logger,
private RemoveOldHistoryService $removeOldHistoryService,
) {
parent::__construct($time);
$this->setInterval(3600 * 24);
}
protected function run($arguments): void {
$this->logger->debug('RemoveOldHistoryTask: Starting cleanup of old history records');
try {
$deletedCount = $this->removeOldHistoryService->removeOldHistory();
$this->logger->info("RemoveOldHistoryTask: Successfully removed {$deletedCount} old history records");
} catch (\Exception $e) {
$this->logger->error('RemoveOldHistoryTask: Failed to remove old history records: ' . $e->getMessage(), ['exception' => $e]);
}
}
}

View File

@@ -94,4 +94,20 @@ class AutocurrencyRateHistoryMapper extends QBMapper {
return $this->findEntities($qb);
}
/**
* Delete all history records older than the specified date
*
* @param DateTimeInterface $cutoffDate Delete records with fetched_at before this date
* @return int Number of rows deleted
*/
public function deleteOlderThan(DateTimeInterface $cutoffDate): int {
$qb = $this->db->getQueryBuilder();
$qb->delete($this->getTableName())
->where(
$qb->expr()->lt('fetched_at', $qb->createNamedParameter($cutoffDate->format('Y-m-d H:i:s')))
);
return $qb->executeStatement();
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: Chen Asraf <contact@casraf.dev>
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\AutoCurrency\Service;
use DateTimeImmutable;
use OCA\AutoCurrency\AppInfo;
use OCA\AutoCurrency\Db\AutocurrencyRateHistoryMapper;
use OCP\IAppConfig;
use Psr\Log\LoggerInterface;
class RemoveOldHistoryService {
public function __construct(
private LoggerInterface $logger,
private IAppConfig $config,
private AutocurrencyRateHistoryMapper $historyMapper,
) {
//
}
/**
* Remove old history records based on the retention_days setting
* If retention_days is 0, no records are deleted (no limit)
* If retention_days is > 0, records older than that many days are deleted
*
* @return int Number of records deleted
*/
public function removeOldHistory(): int {
$retentionDays = $this->config->getValueInt(AppInfo\Application::APP_ID, 'retention_days', 30);
// If retention is 0, don't delete anything (no limit)
if ($retentionDays === 0) {
$this->logger->debug('History retention is set to 0 (no limit), skipping cleanup');
return 0;
}
// Calculate the cutoff date
$cutoffDate = new DateTimeImmutable("-{$retentionDays} days");
$this->logger->info("Removing history records older than {$retentionDays} days (before {$cutoffDate->format('Y-m-d H:i:s')})");
// Delete old records
$deletedCount = $this->historyMapper->deleteOlderThan($cutoffDate);
$this->logger->info("Removed {$deletedCount} old history records");
return $deletedCount;
}
}

View File

@@ -100,7 +100,8 @@
"type": "object",
"required": [
"last_update",
"interval"
"interval",
"retention_days"
],
"properties": {
"last_update": {
@@ -111,6 +112,10 @@
"interval": {
"type": "integer",
"format": "int64"
},
"retention_days": {
"type": "integer",
"format": "int64"
}
}
}
@@ -214,6 +219,10 @@
"interval": {
"type": "integer",
"format": "int64"
},
"retention_days": {
"type": "integer",
"format": "int64"
}
}
}

View File

@@ -100,7 +100,8 @@
"type": "object",
"required": [
"last_update",
"interval"
"interval",
"retention_days"
],
"properties": {
"last_update": {
@@ -111,6 +112,10 @@
"interval": {
"type": "integer",
"format": "int64"
},
"retention_days": {
"type": "integer",
"format": "int64"
}
}
}
@@ -214,6 +219,10 @@
"interval": {
"type": "integer",
"format": "int64"
},
"retention_days": {
"type": "integer",
"format": "int64"
}
}
}

View File

@@ -11,110 +11,125 @@
<p v-html="strings.customCurrenciesHelp" />
</NcNoteCard>
<div class="custom-currencies-list">
<div
v-for="(currency, index) in customCurrencies"
:key="currency.tempId || currency.id"
class="currency-item"
>
<div class="currency-fields">
<div class="field-row">
<NcTextField
v-model="currency.code"
:label="strings.currencyCode"
:placeholder="strings.currencyCodePlaceholder"
required
:disabled="loading"
/>
<NcTextField
v-model="currency.symbol"
:label="strings.currencySymbol"
:placeholder="strings.currencySymbolPlaceholder"
:disabled="loading"
/>
<div class="settings-section">
<div class="custom-currencies-list">
<div
v-for="(currency, index) in customCurrencies"
:key="currency.tempId || currency.id"
class="currency-item"
>
<div class="currency-fields">
<div class="field-row">
<NcTextField
v-model="currency.code"
:label="strings.currencyCode"
:placeholder="strings.currencyCodePlaceholder"
required
:disabled="loading"
/>
<NcTextField
v-model="currency.symbol"
:label="strings.currencySymbol"
:placeholder="strings.currencySymbolPlaceholder"
:disabled="loading"
/>
</div>
<div class="field-row">
<NcTextField
v-model="currency.api_endpoint"
:label="strings.apiEndpoint"
type="url"
:placeholder="strings.apiEndpointPlaceholder"
required
:disabled="loading"
/>
</div>
<div class="field-row">
<NcTextField
v-model="currency.api_key"
:label="strings.apiKey"
type="password"
:placeholder="strings.apiKeyPlaceholder"
:disabled="loading"
/>
<NcTextField
v-model="currency.json_path"
:label="strings.jsonPath"
:placeholder="strings.jsonPathPlaceholder"
required
:disabled="loading"
/>
</div>
</div>
<div class="field-row">
<NcTextField
v-model="currency.api_endpoint"
:label="strings.apiEndpoint"
type="url"
:placeholder="strings.apiEndpointPlaceholder"
required
:disabled="loading"
/>
</div>
<div class="field-row">
<NcTextField
v-model="currency.api_key"
:label="strings.apiKey"
type="password"
:placeholder="strings.apiKeyPlaceholder"
:disabled="loading"
/>
<NcTextField
v-model="currency.json_path"
:label="strings.jsonPath"
:placeholder="strings.jsonPathPlaceholder"
required
:disabled="loading"
/>
</div>
<NcButton
type="error"
@click="removeCurrency(index)"
:disabled="loading"
:aria-label="strings.deleteCurrency"
>
<template #icon>
<Delete :size="20" />
</template>
</NcButton>
</div>
<NcButton
type="error"
@click="removeCurrency(index)"
:disabled="loading"
:aria-label="strings.deleteCurrency"
>
<NcButton @click="addCurrency" :disabled="loading">
<template #icon>
<Delete :size="20" />
<Plus :size="20" />
</template>
{{ strings.addCurrency }}
</NcButton>
</div>
<NcButton @click="addCurrency" :disabled="loading">
<template #icon>
<Plus :size="20" />
</template>
{{ strings.addCurrency }}
</NcButton>
</div>
<div class="submit-buttons">
<NcButton type="primary" @click="saveCustomCurrencies" :disabled="loading">
{{ strings.save }}
</NcButton>
<div class="submit-buttons">
<NcButton type="primary" @click="saveCustomCurrencies" :disabled="loading">
{{ strings.save }}
</NcButton>
</div>
</div>
</NcAppSettingsSection>
<NcAppSettingsSection id="cron-settings" :name="strings.cronSettingsHeader">
<section>
<form @submit.prevent="save">
<div class="cron-flex">
<NcSelect
v-model="interval"
:options="intervals"
:input-label="strings.intervalLabel"
required
:disabled="loading"
/>
<div class="settings-section">
<div class="cron-flex">
<NcSelect
v-model="interval"
:options="intervals"
:input-label="strings.intervalLabel"
required
:disabled="loading"
/>
<div class="cron-last-update-container">
<NcButton @click="doCron" :disabled="loading">{{ strings.fetchNow }}</NcButton>
<div class="cron-last-update-container">
<NcButton @click="doCron" :disabled="loading">{{ strings.fetchNow }}</NcButton>
<div>
{{ strings.lastFetched }}
<span v-if="loading">{{ strings.loading }}</span>
<span v-if="!loading && !lastUpdate">{{ strings.never }}</span>
<NcDateTime v-if="!loading && lastUpdate" :timestamp="lastUpdate.valueOf()" />
<div>
{{ strings.lastFetched }}
<span v-if="loading">{{ strings.loading }}</span>
<span v-if="!loading && !lastUpdate">{{ strings.never }}</span>
<NcDateTime v-if="!loading && lastUpdate" :timestamp="lastUpdate.valueOf()" />
</div>
</div>
</div>
</div>
<div class="submit-buttons">
<NcButton type="submit">{{ strings.save }}</NcButton>
<div class="retention-field">
<NcTextField
v-model="retentionDays"
type="number"
:label="strings.retentionDaysLabel"
:helper-text="strings.retentionDaysHelp"
min="0"
required
:disabled="loading"
/>
</div>
<div class="submit-buttons">
<NcButton type="submit">{{ strings.save }}</NcButton>
</div>
</div>
</form>
</section>
@@ -154,6 +169,7 @@ export default {
return {
loading: true,
interval: null,
retentionDays: 30,
lastUpdate: null,
customCurrencies: [],
originalCustomCurrencies: [],
@@ -192,6 +208,7 @@ export default {
addCurrency: t(APP_ID, 'Add Currency'),
deleteCurrency: t(APP_ID, 'Delete Currency'),
cronSettingsHeader: t(APP_ID, 'Cron Settings'),
intervalLabel: t(APP_ID, 'Update Interval'),
instructionsHelp: t(
APP_ID,
'See the {aStart}Personal settings{aEnd} to view instructions on how to set up your currencies.',
@@ -207,6 +224,11 @@ export default {
loading: t(APP_ID, 'Loading…'),
never: t(APP_ID, 'Never'),
save: t(APP_ID, 'Save'),
retentionDaysLabel: t(APP_ID, 'History Retention (days)'),
retentionDaysHelp: t(
APP_ID,
'Number of days to keep currency history. Set to 0 for no limit (default: 30)',
),
},
}
},
@@ -231,6 +253,10 @@ export default {
console.warn('Invalid interval value', data.interval)
}
if (data.retention_days !== undefined) {
this.retentionDays = data.retention_days
}
if (data.last_update) {
const lastUpdate = parseDate(data.last_update, new Date())
this.lastUpdate = lastUpdate
@@ -259,7 +285,13 @@ export default {
try {
this.loading = true
const interval = this.getIntervalByLabel(this.interval)?.value ?? 24
const resp = await ocs.put('/settings', { data: { interval } })
const retentionDays = this.retentionDays ?? 30
const resp = await ocs.put('/settings', {
data: {
interval,
retention_days: retentionDays,
},
})
const data = resp.data
this.loading = false
console.debug('[DEBUG] Auto Currency settings saved', data)
@@ -347,10 +379,6 @@ export default {
margin-top: 0;
}
.submit-buttons {
margin-top: 16px;
}
.cron-flex {
display: flex;
align-items: start;
@@ -395,5 +423,16 @@ export default {
}
}
}
.settings-section {
display: flex;
flex-direction: column;
gap: 32px;
}
.retention-field {
max-width: 300px;
width: 100%;
}
}
</style>