pull/24551/merge
Pacien B 2025-12-17 20:47:19 +01:00 committed by GitHub
commit b725493d34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 93 additions and 6 deletions

View File

@ -12455,6 +12455,14 @@
"format": "uuid", "format": "uuid",
"type": "string" "type": "string"
} }
},
{
"name": "untagDescendants",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
} }
], ],
"requestBody": { "requestBody": {

View File

@ -4695,14 +4695,17 @@ export function updateTag({ id, tagUpdateDto }: {
/** /**
* Untag assets * Untag assets
*/ */
export function untagAssets({ id, bulkIdsDto }: { export function untagAssets({ id, untagDescendants, bulkIdsDto }: {
id: string; id: string;
untagDescendants?: boolean;
bulkIdsDto: BulkIdsDto; bulkIdsDto: BulkIdsDto;
}, opts?: Oazapfts.RequestOpts) { }, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: BulkIdResponseDto[]; data: BulkIdResponseDto[];
}>(`/tags/${encodeURIComponent(id)}/assets`, oazapfts.json({ }>(`/tags/${encodeURIComponent(id)}/assets${QS.query(QS.explode({
untagDescendants
}))}`, oazapfts.json({
...opts, ...opts,
method: "DELETE", method: "DELETE",
body: bulkIdsDto body: bulkIdsDto

View File

@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Query, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { Endpoint, HistoryBuilder } from 'src/decorators'; import { Endpoint, HistoryBuilder } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
@ -10,6 +10,7 @@ import {
TagResponseDto, TagResponseDto,
TagUpdateDto, TagUpdateDto,
TagUpsertDto, TagUpsertDto,
UntagAssetsOptionsDto,
} from 'src/dtos/tag.dto'; } from 'src/dtos/tag.dto';
import { ApiTag, Permission } from 'src/enum'; import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
@ -125,7 +126,8 @@ export class TagController {
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Body() dto: BulkIdsDto, @Body() dto: BulkIdsDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@Query() { untagDescendants }: UntagAssetsOptionsDto,
): Promise<BulkIdResponseDto[]> { ): Promise<BulkIdResponseDto[]> {
return this.service.removeAssets(auth, id, dto); return this.service.removeAssets(auth, id, dto, untagDescendants);
} }
} }

View File

@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { IsHexColor, IsNotEmpty, IsString } from 'class-validator'; import { IsHexColor, IsNotEmpty, IsString, IsBoolean } from 'class-validator';
import { Tag } from 'src/database'; import { Tag } from 'src/database';
import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation'; import { Optional, ValidateHexColor, ValidateUUID } from 'src/validation';
@ -41,6 +41,11 @@ export class TagBulkAssetsResponseDto {
count!: number; count!: number;
} }
export class UntagAssetsOptionsDto {
@IsBoolean()
untagDescendants?: boolean;
}
export class TagResponseDto { export class TagResponseDto {
id!: string; id!: string;
parentId?: string; parentId?: string;

View File

@ -128,6 +128,17 @@ export class TagRepository {
await this.db.deleteFrom('tag_asset').where('tagId', '=', tagId).where('assetId', 'in', assetIds).execute(); await this.db.deleteFrom('tag_asset').where('tagId', '=', tagId).where('assetId', 'in', assetIds).execute();
} }
@GenerateSql({ params: [DummyValue.UUID] })
async getDescendantIds(tagId: string): Promise<string[]> {
const results = await this.db
.selectFrom('tag_closure')
.select('id_descendant')
.where('id_ancestor', '=', tagId)
.execute();
return results.map(({ id_descendant }) => id_descendant);
}
@GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagIds: DummyValue.UUID }]] }) @GenerateSql({ params: [[{ assetId: DummyValue.UUID, tagIds: DummyValue.UUID }]] })
@Chunked() @Chunked()
upsertAssetIds(items: Insertable<TagAssetTable>[]) { upsertAssetIds(items: Insertable<TagAssetTable>[]) {

View File

@ -272,6 +272,55 @@ describe(TagService.name, () => {
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']); expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']); expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1']);
}); });
it('should remove assets from parent tag but not child tags', async () => {
mocks.tag.get.mockResolvedValue(tagStub.tag);
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.tag.removeAssetIds.mockResolvedValue();
mocks.tag.getDescendantIds.mockResolvedValue(['tag-1', 'tag-child-1', 'tag-child-2']);
await expect(
sut.removeAssets(authStub.admin, 'tag-1', {
ids: ['asset-1', 'asset-2'],
}),
).resolves.toEqual([
{ id: 'asset-1', success: true },
{ id: 'asset-2', success: true },
]);
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.getDescendantIds).not.toHaveBeenCalled();
expect(mocks.tag.removeAssetIds).not.toHaveBeenCalledWith('tag-child-1', ['asset-1', 'asset-2']);
expect(mocks.tag.removeAssetIds).not.toHaveBeenCalledWith('tag-child-2', ['asset-1', 'asset-2']);
});
it('should remove assets from parent tag and all child tags when asked', async () => {
mocks.tag.get.mockResolvedValue(tagStub.tag);
mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1', 'asset-2']));
mocks.tag.removeAssetIds.mockResolvedValue();
mocks.tag.getDescendantIds.mockResolvedValue(['tag-1', 'tag-child-1', 'tag-child-2']);
await expect(
sut.removeAssets(
authStub.admin,
'tag-1',
{
ids: ['asset-1', 'asset-2'],
},
true,
),
).resolves.toEqual([
{ id: 'asset-1', success: true },
{ id: 'asset-2', success: true },
]);
expect(mocks.tag.getAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.getDescendantIds).toHaveBeenCalledWith('tag-1');
expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-1', ['asset-1', 'asset-2']);
expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-child-1', ['asset-1', 'asset-2']);
expect(mocks.tag.removeAssetIds).toHaveBeenCalledWith('tag-child-2', ['asset-1', 'asset-2']);
});
}); });
describe('handleTagCleanup', () => { describe('handleTagCleanup', () => {

View File

@ -114,7 +114,7 @@ export class TagService extends BaseService {
return results; return results;
} }
async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto): Promise<BulkIdResponseDto[]> { async removeAssets(auth: AuthDto, id: string, dto: BulkIdsDto, untagDescendants?: boolean): Promise<BulkIdResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.TagAsset, ids: [id] }); await this.requireAccess({ auth, permission: Permission.TagAsset, ids: [id] });
const results = await removeAssets( const results = await removeAssets(
@ -123,6 +123,15 @@ export class TagService extends BaseService {
{ parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TagDelete }, { parentId: id, assetIds: dto.ids, canAlwaysRemove: Permission.TagDelete },
); );
if(untagDescendants) {
const descendantTagIds = await this.tagRepository.getDescendantIds(id);
for (const descendantTagId of descendantTagIds) {
if (descendantTagId !== id) {
await this.tagRepository.removeAssetIds(descendantTagId, dto.ids);
}
}
}
for (const { id: assetId, success } of results) { for (const { id: assetId, success } of results) {
if (success) { if (success) {
await this.eventRepository.emit('AssetUntag', { assetId }); await this.eventRepository.emit('AssetUntag', { assetId });