feat: nc admin page rebuild task

This commit is contained in:
2026-03-25 11:36:54 +02:00
parent 026dd21d85
commit 6e84cb2ceb
6 changed files with 366 additions and 71 deletions

View File

@@ -13,5 +13,5 @@ module.exports = {
}
return commands
},
'*Controller.php': [() => 'make openapi', () => 'git add openapi-*.json'],
'*Controller.php': [() => 'make openapi', () => 'git add openapi.json openapi-*.json'],
}

View File

@@ -15,6 +15,7 @@ use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Migration\SeedHelper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\StatsService;
use OCA\Forum\Service\UserRoleService;
use OCA\Forum\Service\UserService;
use OCP\AppFramework\Http;
@@ -43,6 +44,7 @@ class AdminController extends OCSController {
private IUserManager $userManager,
private IUserSession $userSession,
private AdminSettingsService $settingsService,
private StatsService $statsService,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
@@ -296,6 +298,54 @@ class AdminController extends OCSController {
}
}
/**
* Rebuild all forum statistics (users, categories, threads)
*
* @return DataResponse<Http::STATUS_OK, array{success: bool, message: string}, array{}>
*
* 200: Stats rebuilt successfully
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'POST', url: '/api/admin/rebuild-stats')]
public function rebuildStats(): DataResponse {
try {
$userResult = $this->statsService->rebuildAllUserStats();
$categoryResult = $this->statsService->rebuildAllCategoryStats();
$threadResult = $this->statsService->rebuildAllThreadStats();
$messages = [];
$messages[] = sprintf(
'Users processed: %d, created: %d, updated: %d',
$userResult['users'],
$userResult['created'],
$userResult['updated']
);
$messages[] = sprintf(
'Categories processed: %d, updated: %d',
$categoryResult['categories'],
$categoryResult['updated']
);
$messages[] = sprintf(
'Threads processed: %d, updated: %d',
$threadResult['threads'],
$threadResult['updated']
);
$this->logger->info('Forum stats rebuild completed successfully');
return new DataResponse([
'success' => true,
'message' => implode("\n", $messages),
]);
} catch (\Exception $e) {
$this->logger->error('Error rebuilding stats: ' . $e->getMessage());
return new DataResponse([
'success' => false,
'message' => 'Failed to rebuild stats: ' . $e->getMessage(),
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**
* Get all available roles
*

View File

@@ -466,6 +466,107 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/rebuild-stats": {
"post": {
"operationId": "admin-rebuild-stats",
"summary": "Rebuild all forum statistics (users, categories, threads)",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Stats rebuilt successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success",
"message"
],
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles/{roleId}": {
"delete": {
"operationId": "admin-remove-role",

View File

@@ -466,6 +466,107 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/rebuild-stats": {
"post": {
"operationId": "admin-rebuild-stats",
"summary": "Rebuild all forum statistics (users, categories, threads)",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Stats rebuilt successfully",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"success",
"message"
],
"properties": {
"success": {
"type": "boolean"
},
"message": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles/{roleId}": {
"delete": {
"operationId": "admin-remove-role",

View File

@@ -8,17 +8,39 @@
</NcNoteCard>
<div class="settings-section-content">
<div class="repair-seeds-container">
<NcButton :disabled="repairSeedsLoading" @click="runRepairSeeds">
<div class="task-container">
<NcButton :disabled="repairSeeds.loading" @click="runRepairSeeds">
<template #icon>
<WrenchIcon v-if="!repairSeedsLoading" :size="20" />
<WrenchIcon v-if="!repairSeeds.loading" :size="20" />
<NcLoadingIcon v-else :size="20" />
</template>
{{ strings.runRepairSeeds }}
</NcButton>
<NcNoteCard v-if="repairSeedsResult" :type="repairSeedsSuccess ? 'success' : 'error'">
<pre class="repair-seeds-output">{{ repairSeedsResult }}</pre>
<NcNoteCard v-if="repairSeeds.result" :type="repairSeeds.success ? 'success' : 'error'">
<pre class="task-output">{{ repairSeeds.result }}</pre>
</NcNoteCard>
</div>
</div>
</NcSettingsSection>
<NcSettingsSection :name="strings.rebuildStatsHeader">
<NcNoteCard type="info">
{{ strings.rebuildStatsHelp }}
</NcNoteCard>
<div class="settings-section-content">
<div class="task-container">
<NcButton :disabled="rebuildStats.loading" @click="runRebuildStats">
<template #icon>
<ChartBoxIcon v-if="!rebuildStats.loading" :size="20" />
<NcLoadingIcon v-else :size="20" />
</template>
{{ strings.runRebuildStats }}
</NcButton>
<NcNoteCard v-if="rebuildStats.result" :type="rebuildStats.success ? 'success' : 'error'">
<pre class="task-output">{{ rebuildStats.result }}</pre>
</NcNoteCard>
</div>
</div>
@@ -42,7 +64,7 @@
id="user-id"
v-model="userId"
:placeholder="strings.userIdPlaceholder"
:disabled="assignRoleLoading"
:disabled="assignRole.loading"
/>
</div>
<div class="form-group">
@@ -52,7 +74,7 @@
v-model="selectedRole"
:options="roleOptions"
:placeholder="strings.rolePlaceholder"
:disabled="assignRoleLoading || rolesLoading"
:disabled="assignRole.loading || rolesLoading"
:loading="rolesLoading"
/>
</div>
@@ -61,19 +83,19 @@
<div class="button-row">
<NcButton
variant="primary"
:disabled="!canAssignRole || assignRoleLoading"
@click="assignRole"
:disabled="!canAssignRole || assignRole.loading"
@click="runAssignRole"
>
<template #icon>
<PlusIcon v-if="!assignRoleLoading" :size="20" />
<PlusIcon v-if="!assignRole.loading" :size="20" />
<NcLoadingIcon v-else :size="20" />
</template>
{{ strings.assignRole }}
</NcButton>
</div>
<NcNoteCard v-if="assignRoleResult" :type="assignRoleSuccess ? 'success' : 'error'">
<p>{{ assignRoleResult }}</p>
<NcNoteCard v-if="assignRole.result" :type="assignRole.success ? 'success' : 'error'">
<p>{{ assignRole.result }}</p>
</NcNoteCard>
</div>
</div>
@@ -89,11 +111,31 @@ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import WrenchIcon from '@icons/Wrench.vue'
import ChartBoxIcon from '@icons/ChartBox.vue'
import PlusIcon from '@icons/Plus.vue'
import { ocs } from '@/axios'
import { t } from '@nextcloud/l10n'
function createTask() {
return { loading: false, result: null, success: false }
}
async function runTask(task, fn, fallbackError) {
try {
task.loading = true
task.result = null
await fn(task)
} catch (e) {
console.error(fallbackError, e)
task.success = false
task.result =
e.response?.data?.message || e.response?.data?.error || e.message || t('forum', fallbackError)
} finally {
task.loading = false
}
}
export default {
name: 'AdminSettings',
components: {
@@ -104,14 +146,14 @@ export default {
NcTextField,
NcLoadingIcon,
WrenchIcon,
ChartBoxIcon,
PlusIcon,
},
data() {
return {
// Repair seeds
repairSeedsLoading: false,
repairSeedsResult: null,
repairSeedsSuccess: false,
repairSeeds: createTask(),
rebuildStats: createTask(),
assignRole: createTask(),
// User roles
rolesLoading: true,
@@ -119,9 +161,6 @@ export default {
roles: [],
userId: '',
selectedRole: null,
assignRoleLoading: false,
assignRoleResult: null,
assignRoleSuccess: false,
strings: {
title: t('forum', 'Forum'),
@@ -131,10 +170,16 @@ export default {
'Run the repair database initial data command to restore default forum data (roles, categories, permissions, BBCodes). This is safe to run multiple times as it will skip data that already exists.',
),
runRepairSeeds: t('forum', 'Run Repair Database Initial Data'),
rebuildStatsHeader: t('forum', 'Rebuild Statistics'),
rebuildStatsHelp: t(
'forum',
'Recalculate all forum statistics including account post counts, thread counts, and category counters. Use this if statistics appear incorrect or out of sync.',
),
runRebuildStats: t('forum', 'Rebuild Statistics'),
userRolesHeader: t('forum', 'User Roles'),
userRolesHelp: t(
'forum',
'Assign forum roles to users. This allows you to grant administrative or moderator privileges to specific users.',
'Assign forum roles to accounts. This allows you to grant administrative or moderator privileges to specific accounts.',
),
userIdLabel: t('forum', 'User ID'),
userIdPlaceholder: t('forum', 'Enter user ID'),
@@ -172,56 +217,49 @@ export default {
this.rolesLoading = false
}
},
async runRepairSeeds() {
try {
this.repairSeedsLoading = true
this.repairSeedsResult = null
const resp = await ocs.post('/admin/repair-seeds')
this.repairSeedsSuccess = resp.data.success
this.repairSeedsResult = resp.data.message
if (resp.data.success) {
await this.fetchRoles()
}
} catch (e) {
console.error('Failed to run repair seeds', e)
this.repairSeedsSuccess = false
// Extract error message from various possible locations in the response
const errorMessage =
e.response?.data?.message ||
e.response?.data?.error ||
e.message ||
t('forum', 'Failed to run repair database initial data')
this.repairSeedsResult = errorMessage
} finally {
this.repairSeedsLoading = false
}
runRepairSeeds() {
return runTask(
this.repairSeeds,
async (task) => {
const resp = await ocs.post('/admin/repair-seeds')
task.success = resp.data.success
task.result = resp.data.message
if (resp.data.success) {
await this.fetchRoles()
}
},
'Failed to run repair database initial data',
)
},
async assignRole() {
runRebuildStats() {
return runTask(
this.rebuildStats,
async (task) => {
const resp = await ocs.post('/admin/rebuild-stats')
task.success = resp.data.success
task.result = resp.data.message
},
'Failed to rebuild statistics',
)
},
runAssignRole() {
if (!this.canAssignRole) return
try {
this.assignRoleLoading = true
this.assignRoleResult = null
const resp = await ocs.post(
`/admin/users/${encodeURIComponent(this.userId.trim())}/roles`,
{
roleId: this.selectedRole.id,
},
)
this.assignRoleSuccess = resp.data.success
this.assignRoleResult = resp.data.message
if (resp.data.success) {
// Clear form on success
this.userId = ''
this.selectedRole = null
}
} catch (e) {
console.error('Failed to assign role', e)
this.assignRoleSuccess = false
this.assignRoleResult = e.response?.data?.message || t('forum', 'Failed to assign role')
} finally {
this.assignRoleLoading = false
}
return runTask(
this.assignRole,
async (task) => {
const resp = await ocs.post(
`/admin/users/${encodeURIComponent(this.userId.trim())}/roles`,
{ roleId: this.selectedRole.id },
)
task.success = resp.data.success
task.result = resp.data.message
if (resp.data.success) {
this.userId = ''
this.selectedRole = null
}
},
'Failed to assign role',
)
},
},
}
@@ -241,14 +279,14 @@ export default {
margin-top: 16px;
}
.repair-seeds-container {
.task-container {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 600px;
}
.repair-seeds-output {
.task-output {
white-space: pre-wrap;
word-break: break-word;
margin: 0;

View File

@@ -12,6 +12,7 @@ use OCA\Forum\Db\PostMapper;
use OCA\Forum\Db\RoleMapper;
use OCA\Forum\Db\ThreadMapper;
use OCA\Forum\Service\AdminSettingsService;
use OCA\Forum\Service\StatsService;
use OCA\Forum\Service\UserRoleService;
use OCA\Forum\Service\UserService;
use OCP\IRequest;
@@ -45,6 +46,8 @@ class AdminControllerTest extends TestCase {
private IUserSession $userSession;
/** @var AdminSettingsService&MockObject */
private AdminSettingsService $settingsService;
/** @var StatsService&MockObject */
private StatsService $statsService;
/** @var LoggerInterface&MockObject */
private LoggerInterface $logger;
/** @var IRequest&MockObject */
@@ -62,6 +65,7 @@ class AdminControllerTest extends TestCase {
$this->userManager = $this->createMock(IUserManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->settingsService = $this->createMock(AdminSettingsService::class);
$this->statsService = $this->createMock(StatsService::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->controller = new AdminController(
@@ -77,6 +81,7 @@ class AdminControllerTest extends TestCase {
$this->userManager,
$this->userSession,
$this->settingsService,
$this->statsService,
$this->logger
);
}