Skip to content

Commit 61a1040

Browse files
hefftichCopilot
andcommitted
refactor(teams): store folder quotas in team settings
Co-authored-by: Copilot <copilot@github.com> Signed-off-by: Stefan Lender <Stefan.Lender@dataport.de>
1 parent 9492150 commit 61a1040

15 files changed

Lines changed: 194 additions & 130 deletions

‎appinfo/routes.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
['name' => 'Admin#circles', 'url' => '/admin/{emulated}/circles', 'verb' => 'GET'],
6060
['name' => 'Admin#circlesAll', 'url' => '/admin/circlesall', 'verb' => 'GET'],
6161
['name' => 'Admin#teamFolders', 'url' => '/admin/teamfolders', 'verb' => 'GET'],
62+
['name' => 'Admin#updateTeamFolderDefaultQuota', 'url' => '/admin/teamfolders/{circleId}/default-quota', 'verb' => 'PUT'],
6263
['name' => 'Admin#create', 'url' => '/admin/{emulated}/circles', 'verb' => 'POST'],
6364
['name' => 'Admin#destroy', 'url' => '/admin/{emulated}/circles/{circleId}', 'verb' => 'DELETE'],
6465
[

‎lib/ConfigLexicon.php‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@ class ConfigLexicon implements ILexicon {
2626
public const REMOVE_SHARE_TOKENS_DONE = 'remove_share_tokens_done';
2727
public const TEAM_FOLDER_AUTO_CREATE = 'team_folder_auto_create';
2828
public const TEAM_FOLDER_DEFAULT_QUOTA = 'team_folder_default_quota';
29-
public const TEAM_FOLDER_QUOTAS = 'team_folder_quotas';
3029

3130
// OIDC
3231
public const OIDC_ENABLED = 'oidc_enabled';
@@ -60,7 +59,6 @@ public function getAppConfigs(): array {
6059
new Entry(key: self::REMOVE_SHARE_TOKENS_DONE, type: ValueType::BOOL, defaultRaw: false, definition: 'whether the remove share tokens repair step has already been executed', lazy: true),
6160
new Entry(key: self::TEAM_FOLDER_AUTO_CREATE, type: ValueType::BOOL, defaultRaw: true, definition: 'whether Circles auto-creates team folders and allows UI/API upgrade (not exposed in admin UI)', lazy: true),
6261
new Entry(key: self::TEAM_FOLDER_DEFAULT_QUOTA, type: ValueType::INT, defaultRaw: 104857600, definition: 'default quota in bytes for all team folders (0 means unlimited)', lazy: true),
63-
new Entry(key: self::TEAM_FOLDER_QUOTAS, type: ValueType::ARRAY, defaultRaw: [], definition: 'team folder quota overrides in bytes by team ID (0 means unlimited)', lazy: true),
6462
// OIDC
6563
new Entry(key: self::OIDC_ENABLED, type: ValueType::BOOL, defaultRaw: false, definition: 'disable/enable OIDC integration', lazy: true),
6664
new Entry(key: self::OIDC_ISSUER, type: ValueType::STRING, defaultRaw: '', definition: 'provider issuer URL', lazy: true),

‎lib/Controller/AdminController.php‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,13 @@
2828
use OCA\Circles\Service\MemberService;
2929
use OCA\Circles\Service\MembershipService;
3030
use OCA\Circles\Service\SearchService;
31+
use OCA\Circles\Service\TeamFolderPolicy;
3132
use OCA\Circles\Tools\Traits\TDeserialize;
3233
use OCA\Circles\Tools\Traits\TNCLogger;
34+
use OCP\AppFramework\Http;
3335
use OCP\AppFramework\Http\DataResponse;
3436
use OCP\AppFramework\OCS\OCSException;
37+
use OCP\AppFramework\OCS\OCSNotFoundException;
3538
use OCP\AppFramework\OCSController;
3639
use OCP\IRequest;
3740
use OCP\IUserSession;
@@ -72,6 +75,7 @@ public function __construct(
7275
private MembershipService $membershipService,
7376
private SearchService $searchService,
7477
private ITeamManager $teamManager,
78+
private TeamFolderPolicy $teamFolderPolicy,
7579
ConfigService $configService,
7680
) {
7781
parent::__construct($appName, $request);
@@ -293,6 +297,7 @@ public function teamFolders(): DataResponse {
293297
$teamFolders[] = [
294298
'teamId' => $circle->getSingleId(),
295299
'teamName' => $circle->getDisplayName(),
300+
'defaultQuota' => $this->teamFolderPolicy->getTeamFolderQuota($circle),
296301
'folder' => $folder?->jsonSerialize(),
297302
];
298303
}
@@ -304,6 +309,32 @@ public function teamFolders(): DataResponse {
304309
}
305310
}
306311

312+
public function updateTeamFolderDefaultQuota(string $circleId, ?int $quota): DataResponse {
313+
try {
314+
$user = $this->userSession->getUser();
315+
if ($user === null) {
316+
throw new OCSException('Authentication required', Http::STATUS_UNAUTHORIZED);
317+
}
318+
319+
$this->setLocalFederatedUser($user->getUID());
320+
$circle = $this->circleService->getCircle($circleId);
321+
if ($quota === null) {
322+
$this->teamFolderPolicy->removeTeamFolderQuota($circle);
323+
} else {
324+
$this->teamFolderPolicy->setTeamFolderQuota($circle, $quota);
325+
}
326+
327+
return new DataResponse([
328+
'teamId' => $circleId,
329+
'defaultQuota' => $this->teamFolderPolicy->getTeamFolderQuota($circle),
330+
]);
331+
} catch (\InvalidArgumentException $e) {
332+
throw new OCSException($e->getMessage(), Http::STATUS_BAD_REQUEST);
333+
} catch (\OCA\Circles\Exceptions\CircleNotFoundException) {
334+
throw new OCSNotFoundException('Team not found');
335+
}
336+
}
337+
307338
/**
308339
* @param string $emulated
309340
* @param string $circleId

‎lib/Controller/SettingsController.php‎

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -42,20 +42,6 @@ public function setValue(string $key, string $value): DataResponse {
4242
return $this->getValues();
4343
}
4444

45-
if ($key === ConfigLexicon::TEAM_FOLDER_QUOTAS) {
46-
try {
47-
$quotas = json_decode($value, true, flags: JSON_THROW_ON_ERROR);
48-
if (!is_array($quotas) || array_is_list($quotas)) {
49-
throw new \InvalidArgumentException('quotas must be an object');
50-
}
51-
$this->teamFolderPolicy->setQuotas($quotas);
52-
} catch (\JsonException|\InvalidArgumentException $e) {
53-
return new DataResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_BAD_REQUEST);
54-
}
55-
56-
return $this->getValues();
57-
}
58-
5945
if ($key === ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA) {
6046
if (!preg_match('/^\d+$/', $value)) {
6147
return new DataResponse(['data' => ['message' => 'default quota must be a non-negative integer']], Http::STATUS_BAD_REQUEST);
@@ -78,7 +64,6 @@ public function getValues(): DataResponse {
7864
ConfigLexicon::FEDERATED_TEAMS_FRONTAL => $this->getFrontalValue() ?? '',
7965
ConfigLexicon::FEDERATED_TEAMS_ENABLED => $this->appConfig->getAppValueBool(ConfigLexicon::FEDERATED_TEAMS_ENABLED),
8066
ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA => $this->teamFolderPolicy->getDefaultQuota(),
81-
ConfigLexicon::TEAM_FOLDER_QUOTAS => $this->teamFolderPolicy->getQuotas(),
8267
]);
8368
}
8469

@@ -98,7 +83,7 @@ private function setFrontalValue(string $url): bool {
9883

9984
private function getFrontalValue(): ?string {
10085
if ($this->appConfig->hasAppKey(ConfigLexicon::FEDERATED_TEAMS_FRONTAL)) {
101-
return $this->appConfig->getAppValueString(ConfigLExicon::FEDERATED_TEAMS_FRONTAL);
86+
return $this->appConfig->getAppValueString(ConfigLexicon::FEDERATED_TEAMS_FRONTAL);
10287
}
10388

10489
if (!$this->appConfig->hasAppKey(ConfigService::FRONTAL_CLOUD_SCHEME)

‎lib/Listeners/TeamFolderLifecycleListener.php‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ public function __construct(
3333
public function handle(Event $event): void {
3434
if ($event instanceof DestroyingCircleEvent) {
3535
$circle = $event->getCircle();
36-
$this->policy->removeTeam($circle->getSingleId());
3736

3837
$provider = $this->teamManager->getTeamFolderProvider();
3938
if ($provider === null) {

‎lib/Model/Circle.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class Circle extends ManagedModel implements IEntity, IDeserializable, IQueryRow
9595

9696
// settings array keys
9797
public const SETTING_EXTERNAL_ID = 'external_id';
98+
public const SETTING_TEAM_FOLDER_QUOTA = 'team_folder_quota';
9899

99100
/**
100101
* Note: When editing those values, update lib/Application/Capabilities.php

‎lib/Service/TeamFolderPolicy.php‎

Lines changed: 33 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,18 @@
1010
namespace OCA\Circles\Service;
1111

1212
use OCA\Circles\ConfigLexicon;
13+
use OCA\Circles\Db\CircleRequest;
1314
use OCA\Circles\Db\MembershipRequest;
15+
use OCA\Circles\Exceptions\CircleNotFoundException;
1416
use OCA\Circles\Model\Circle;
15-
use OCA\Circles\Model\Membership;
1617
use OCP\AppFramework\Services\IAppConfig;
1718

1819
/**
1920
* Policy for team folders owned by teams (circles).
2021
*
2122
* This class owns the *policy* for team-folder creation:
2223
* - the `team_folder_auto_create` app config toggle (occ only, not admin UI),
23-
* - the `team_folder_quotas` app config mapping,
24+
* - the default quota and per-team quota settings,
2425
* - the circle-type eligibility rules (personal/hidden/system/backend circles
2526
* are excluded).
2627
*
@@ -37,6 +38,7 @@ class TeamFolderPolicy {
3738
public function __construct(
3839
private IAppConfig $appConfig,
3940
private MembershipRequest $membershipRequest,
41+
private CircleRequest $circleRequest,
4042
) {
4143
}
4244

@@ -102,36 +104,27 @@ public function setDefaultQuota(int $quota): void {
102104
$this->appConfig->setAppValueInt(ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA, $quota);
103105
}
104106

105-
/**
106-
* @return array<string, int>
107-
*/
108-
public function getQuotas(): array {
109-
$quotas = $this->appConfig->getAppValueArray(ConfigLexicon::TEAM_FOLDER_QUOTAS, []);
110-
return array_filter(
111-
$quotas,
112-
static fn (mixed $quota, mixed $teamId): bool => is_string($teamId) && $teamId !== '' && is_int($quota) && $quota >= 0,
113-
ARRAY_FILTER_USE_BOTH,
114-
);
107+
public function getTeamFolderQuota(Circle $circle): ?int {
108+
$quota = $circle->getSettings()[Circle::SETTING_TEAM_FOLDER_QUOTA] ?? null;
109+
110+
return is_int($quota) && $quota >= 0 ? $quota : null;
115111
}
116112

117-
/**
118-
* @param array<string, mixed> $quotas
119-
*/
120-
public function setQuotas(array $quotas): void {
121-
foreach ($quotas as $teamId => $quota) {
122-
if (!is_string($teamId) || trim($teamId) === '' || !is_int($quota) || $quota < 0) {
123-
throw new \InvalidArgumentException('quotas must map non-empty team IDs to non-negative integers');
124-
}
113+
public function setTeamFolderQuota(Circle $circle, int $quota): void {
114+
if ($quota < 0) {
115+
throw new \InvalidArgumentException('team folder quota must be a non-negative integer');
125116
}
126117

127-
$this->appConfig->setAppValueArray(ConfigLexicon::TEAM_FOLDER_QUOTAS, $quotas);
118+
$settings = $circle->getSettings();
119+
$settings[Circle::SETTING_TEAM_FOLDER_QUOTA] = $quota;
120+
$this->circleRequest->updateSettings($circle->setSettings($settings));
128121
}
129122

130-
public function removeTeam(string $teamId): void {
131-
$quotas = $this->getQuotas();
132-
if (array_key_exists($teamId, $quotas)) {
133-
unset($quotas[$teamId]);
134-
$this->setQuotas($quotas);
123+
public function removeTeamFolderQuota(Circle $circle): void {
124+
$settings = $circle->getSettings();
125+
if (array_key_exists(Circle::SETTING_TEAM_FOLDER_QUOTA, $settings)) {
126+
unset($settings[Circle::SETTING_TEAM_FOLDER_QUOTA]);
127+
$this->circleRequest->updateSettings($circle->setSettings($settings));
135128
}
136129
}
137130

@@ -140,18 +133,26 @@ public function removeTeam(string $teamId): void {
140133
* Unlimited (0) takes precedence over every finite quota.
141134
*/
142135
public function getQuotaForCircle(Circle $circle): int {
143-
$quotas = $this->getQuotas();
144136
$fallback = $this->getDefaultQuota();
145137
$owner = $circle->getOwner();
146138
if (!$owner->isLocal()) {
147139
return $fallback;
148140
}
149141

150-
$teamIds = array_map(
151-
static fn (Membership $membership): string => $membership->getCircleId(),
152-
$this->membershipRequest->getMemberships($owner->getSingleId()),
153-
);
154-
$matches = array_intersect_key($quotas, array_flip($teamIds));
142+
$matches = [];
143+
foreach ($this->membershipRequest->getMemberships($owner->getSingleId()) as $membership) {
144+
try {
145+
$membershipCircle = $this->circleRequest->getCircle($membership->getCircleId());
146+
} catch (CircleNotFoundException) {
147+
continue;
148+
}
149+
150+
$quota = $this->getTeamFolderQuota($membershipCircle);
151+
if ($quota !== null) {
152+
$matches[] = $quota;
153+
}
154+
}
155+
155156
if ($matches === []) {
156157
return $fallback;
157158
}

‎lib/Settings/AdminTeamFolders.php‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ public function __construct(
3434
*/
3535
public function getForm(): TemplateResponse {
3636
$this->initialState->provideInitialState('teamFolderDefaultQuota', $this->teamFolderPolicy->getDefaultQuota());
37-
$this->initialState->provideInitialState('teamFolderQuotas', $this->teamFolderPolicy->getQuotas());
3837
$this->initialState->provideInitialState('teamFolderAutoCreateEnabled', $this->teamFolderPolicy->isTeamFolderProvisioningEnabled());
3938

4039
Util::addStyle(Application::APP_ID, 'teams-settings-team-folders');
@@ -69,7 +68,6 @@ public function getAuthorizedAppConfig(): array {
6968
return [
7069
Application::APP_ID => [
7170
ConfigLexicon::TEAM_FOLDER_DEFAULT_QUOTA,
72-
ConfigLexicon::TEAM_FOLDER_QUOTAS,
7371
],
7472
];
7573
}

‎src/components/AdminTeamFolders.vue‎

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import NcSelect from '@nextcloud/vue/components/NcSelect'
2424
import NcTextField from '@nextcloud/vue/components/NcTextField'
2525
import IconDeleteOutline from 'vue-material-design-icons/TrashCanOutline.vue'
2626
import { logger } from '../logger.ts'
27-
import { deleteTeam, getAdminTeamFolders, getLinkableTeamFolders, linkTeamFolder, updateTeamFolderQuota, upgradeTeamFolder } from '../teams/api.ts'
27+
import { deleteTeam, getAdminTeamFolders, getLinkableTeamFolders, linkTeamFolder, updateTeamFolderDefaultQuota, updateTeamFolderQuota, upgradeTeamFolder } from '../teams/api.ts'
2828
2929
interface QuotaOption {
3030
id: string
@@ -52,12 +52,10 @@ const quotaPreset: QuotaOption[] = [
5252
]
5353
5454
const initialDefaultQuota = loadState<number>('circles', 'teamFolderDefaultQuota', 104857600)
55-
const initialQuotas = loadState<Record<string, number>>('circles', 'teamFolderQuotas', {})
5655
const teamFolderAutoCreateEnabled = loadState<boolean>('circles', 'teamFolderAutoCreateEnabled', true)
5756
const defaultQuota = ref<QuotaOption>(quotaOption(initialDefaultQuota))
58-
const rows = ref<QuotaRow[]>(Object.entries(initialQuotas)
59-
.map(([teamId, quota]) => ({ id: teamId, label: teamId, quota: quotaOption(quota) }))
60-
.sort((left, right) => left.label.localeCompare(right.label)))
57+
const rows = ref<QuotaRow[]>([])
58+
const loadedQuotas = ref<Record<string, number>>({})
6159
const teams = ref<TeamOption[]>([])
6260
const selectedQuotaTeam = ref<TeamOption | null>(null)
6361
const loadingTeams = ref(false)
@@ -174,8 +172,13 @@ async function loadTeamFolders() {
174172
teams.value = teamFolders.value
175173
.map((team) => ({ id: team.teamId, label: team.teamName }))
176174
.sort((left, right) => left.label.localeCompare(right.label))
177-
const labels = new Map(teams.value.map((team) => [team.id, team.label]))
178-
rows.value = rows.value.map((row) => ({ ...row, label: labels.get(row.id) ?? row.label }))
175+
loadedQuotas.value = Object.fromEntries(teamFolders.value
176+
.filter((team) => team.defaultQuota !== null)
177+
.map((team) => [team.teamId, team.defaultQuota as number]))
178+
rows.value = teamFolders.value
179+
.filter((team) => team.defaultQuota !== null)
180+
.map((team) => ({ id: team.teamId, label: team.teamName, quota: quotaOption(team.defaultQuota as number) }))
181+
.sort((left, right) => left.label.localeCompare(right.label))
179182
teamFolderQuotas.value = Object.fromEntries(teamFolders.value.map((teamFolder) => [
180183
teamFolder.teamId,
181184
quotaOptionFromBytes(teamFolder.folder?.quota ?? 104857600),
@@ -408,8 +411,32 @@ async function onSaveQuotas() {
408411
409412
saving.value = true
410413
const defaultSaved = await updateAppConfig('team_folder_default_quota', String(Math.round(defaultBytes)))
411-
if (defaultSaved && await updateAppConfig('team_folder_quotas', JSON.stringify(quotas))) {
414+
if (!defaultSaved) {
415+
saving.value = false
416+
return
417+
}
418+
419+
let quotasSaved = true
420+
const changedTeamIds = new Set([...Object.keys(loadedQuotas.value), ...Object.keys(quotas)])
421+
for (const teamId of changedTeamIds) {
422+
const quota = quotas[teamId] ?? null
423+
if ((loadedQuotas.value[teamId] ?? null) === quota) {
424+
continue
425+
}
426+
427+
try {
428+
await updateTeamFolderDefaultQuota(teamId, quota)
429+
} catch (error) {
430+
quotasSaved = false
431+
logger.error('Unable to update default team folder quota', { error, teamId })
432+
}
433+
}
434+
435+
if (quotasSaved) {
412436
showSuccess(t('circles', 'Changed default team folder quotas'))
437+
await loadTeamFolders()
438+
} else {
439+
showError(t('circles', 'Unable to update team folder config'))
413440
}
414441
saving.value = false
415442
}
@@ -504,7 +531,7 @@ onMounted(() => {
504531
:aria-label="t('circles', 'Default quota for all groups')"
505532
:clearable="false"
506533
:createOption="validateQuota"
507-
:input-label="t('circles', 'Default quota')"
534+
:inputLabel="t('circles', 'Default quota')"
508535
:options="quotaOptions"
509536
:placeholder="t('circles', 'Select default quota')"
510537
taggable />

‎src/teams/api.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,7 @@ export interface TeamFolder {
242242
export interface AdminTeamFolder {
243243
teamId: string
244244
teamName: string
245+
defaultQuota: number | null
245246
folder: TeamFolder | null
246247
}
247248

@@ -253,6 +254,20 @@ export async function getAdminTeamFolders(): Promise<AdminTeamFolder[]> {
253254
return data.ocs.data
254255
}
255256

257+
/**
258+
* Update the default folder quota associated with a team.
259+
*
260+
* @param teamId - The team single id
261+
* @param quota - Quota in bytes, zero for unlimited, or null to remove the override
262+
*/
263+
export async function updateTeamFolderDefaultQuota(teamId: string, quota: number | null): Promise<{ teamId: string, defaultQuota: number | null }> {
264+
const { data } = await axios.put<OcsResponse<{ teamId: string, defaultQuota: number | null }>>(
265+
generateOcsUrl('/apps/circles/admin/teamfolders/{circleId}/default-quota', { circleId: teamId }),
266+
{ quota },
267+
)
268+
return data.ocs.data
269+
}
270+
256271
/**
257272
* Fetch the team folder linked to a team.
258273
*

0 commit comments

Comments
 (0)