1
0
mirror of https://github.com/immich-app/immich.git synced 2025-02-15 19:36:04 +02:00

fix(server): album statistics endpoint (#11924)

This commit is contained in:
Jason Rasmussen 2024-08-20 07:50:36 -04:00 committed by GitHub
parent cde0458dc8
commit ef9a06be5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 111 additions and 111 deletions

View File

@ -344,16 +344,16 @@ describe('/albums', () => {
}); });
}); });
describe('GET /albums/count', () => { describe('GET /albums/statistics', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(app).get('/albums/count'); const { status, body } = await request(app).get('/albums/statistics');
expect(status).toBe(401); expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized); expect(body).toEqual(errorDto.unauthorized);
}); });
it('should return total count of albums the user has access to', async () => { it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/albums/count') .get('/albums/statistics')
.set('Authorization', `Bearer ${user1.accessToken}`); .set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200); expect(status).toBe(200);

View File

@ -86,8 +86,8 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users | *AlbumsApi* | [**addUsersToAlbum**](doc//AlbumsApi.md#adduserstoalbum) | **PUT** /albums/{id}/users |
*AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | *AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums |
*AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | *AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} |
*AlbumsApi* | [**getAlbumCount**](doc//AlbumsApi.md#getalbumcount) | **GET** /albums/count |
*AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | *AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} |
*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics |
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums |
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets |
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} |
@ -265,8 +265,8 @@ Class | Method | HTTP request | Description
- [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md) - [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md)
- [AddUsersDto](doc//AddUsersDto.md) - [AddUsersDto](doc//AddUsersDto.md)
- [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md) - [AdminOnboardingUpdateDto](doc//AdminOnboardingUpdateDto.md)
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
- [AlbumResponseDto](doc//AlbumResponseDto.md) - [AlbumResponseDto](doc//AlbumResponseDto.md)
- [AlbumStatisticsResponseDto](doc//AlbumStatisticsResponseDto.md)
- [AlbumUserAddDto](doc//AlbumUserAddDto.md) - [AlbumUserAddDto](doc//AlbumUserAddDto.md)
- [AlbumUserCreateDto](doc//AlbumUserCreateDto.md) - [AlbumUserCreateDto](doc//AlbumUserCreateDto.md)
- [AlbumUserResponseDto](doc//AlbumUserResponseDto.md) - [AlbumUserResponseDto](doc//AlbumUserResponseDto.md)

View File

@ -73,8 +73,8 @@ part 'model/activity_response_dto.dart';
part 'model/activity_statistics_response_dto.dart'; part 'model/activity_statistics_response_dto.dart';
part 'model/add_users_dto.dart'; part 'model/add_users_dto.dart';
part 'model/admin_onboarding_update_dto.dart'; part 'model/admin_onboarding_update_dto.dart';
part 'model/album_count_response_dto.dart';
part 'model/album_response_dto.dart'; part 'model/album_response_dto.dart';
part 'model/album_statistics_response_dto.dart';
part 'model/album_user_add_dto.dart'; part 'model/album_user_add_dto.dart';
part 'model/album_user_create_dto.dart'; part 'model/album_user_create_dto.dart';
part 'model/album_user_response_dto.dart'; part 'model/album_user_response_dto.dart';

View File

@ -218,47 +218,6 @@ class AlbumsApi {
} }
} }
/// Performs an HTTP 'GET /albums/count' operation and returns the [Response].
Future<Response> getAlbumCountWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/albums/count';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<AlbumCountResponseDto?> getAlbumCount() async {
final response = await getAlbumCountWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumCountResponseDto',) as AlbumCountResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /albums/{id}' operation and returns the [Response]. /// Performs an HTTP 'GET /albums/{id}' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///
@ -322,6 +281,47 @@ class AlbumsApi {
return null; return null;
} }
/// Performs an HTTP 'GET /albums/statistics' operation and returns the [Response].
Future<Response> getAlbumStatisticsWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/albums/statistics';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<AlbumStatisticsResponseDto?> getAlbumStatistics() async {
final response = await getAlbumStatisticsWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AlbumStatisticsResponseDto',) as AlbumStatisticsResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /albums' operation and returns the [Response]. /// Performs an HTTP 'GET /albums' operation and returns the [Response].
/// Parameters: /// Parameters:
/// ///

View File

@ -201,10 +201,10 @@ class ApiClient {
return AddUsersDto.fromJson(value); return AddUsersDto.fromJson(value);
case 'AdminOnboardingUpdateDto': case 'AdminOnboardingUpdateDto':
return AdminOnboardingUpdateDto.fromJson(value); return AdminOnboardingUpdateDto.fromJson(value);
case 'AlbumCountResponseDto':
return AlbumCountResponseDto.fromJson(value);
case 'AlbumResponseDto': case 'AlbumResponseDto':
return AlbumResponseDto.fromJson(value); return AlbumResponseDto.fromJson(value);
case 'AlbumStatisticsResponseDto':
return AlbumStatisticsResponseDto.fromJson(value);
case 'AlbumUserAddDto': case 'AlbumUserAddDto':
return AlbumUserAddDto.fromJson(value); return AlbumUserAddDto.fromJson(value);
case 'AlbumUserCreateDto': case 'AlbumUserCreateDto':

View File

@ -10,9 +10,9 @@
part of openapi.api; part of openapi.api;
class AlbumCountResponseDto { class AlbumStatisticsResponseDto {
/// Returns a new [AlbumCountResponseDto] instance. /// Returns a new [AlbumStatisticsResponseDto] instance.
AlbumCountResponseDto({ AlbumStatisticsResponseDto({
required this.notShared, required this.notShared,
required this.owned, required this.owned,
required this.shared, required this.shared,
@ -25,7 +25,7 @@ class AlbumCountResponseDto {
int shared; int shared;
@override @override
bool operator ==(Object other) => identical(this, other) || other is AlbumCountResponseDto && bool operator ==(Object other) => identical(this, other) || other is AlbumStatisticsResponseDto &&
other.notShared == notShared && other.notShared == notShared &&
other.owned == owned && other.owned == owned &&
other.shared == shared; other.shared == shared;
@ -38,7 +38,7 @@ class AlbumCountResponseDto {
(shared.hashCode); (shared.hashCode);
@override @override
String toString() => 'AlbumCountResponseDto[notShared=$notShared, owned=$owned, shared=$shared]'; String toString() => 'AlbumStatisticsResponseDto[notShared=$notShared, owned=$owned, shared=$shared]';
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final json = <String, dynamic>{}; final json = <String, dynamic>{};
@ -48,14 +48,14 @@ class AlbumCountResponseDto {
return json; return json;
} }
/// Returns a new [AlbumCountResponseDto] instance and imports its values from /// Returns a new [AlbumStatisticsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise. /// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods // ignore: prefer_constructors_over_static_methods
static AlbumCountResponseDto? fromJson(dynamic value) { static AlbumStatisticsResponseDto? fromJson(dynamic value) {
if (value is Map) { if (value is Map) {
final json = value.cast<String, dynamic>(); final json = value.cast<String, dynamic>();
return AlbumCountResponseDto( return AlbumStatisticsResponseDto(
notShared: mapValueOfType<int>(json, r'notShared')!, notShared: mapValueOfType<int>(json, r'notShared')!,
owned: mapValueOfType<int>(json, r'owned')!, owned: mapValueOfType<int>(json, r'owned')!,
shared: mapValueOfType<int>(json, r'shared')!, shared: mapValueOfType<int>(json, r'shared')!,
@ -64,11 +64,11 @@ class AlbumCountResponseDto {
return null; return null;
} }
static List<AlbumCountResponseDto> listFromJson(dynamic json, {bool growable = false,}) { static List<AlbumStatisticsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <AlbumCountResponseDto>[]; final result = <AlbumStatisticsResponseDto>[];
if (json is List && json.isNotEmpty) { if (json is List && json.isNotEmpty) {
for (final row in json) { for (final row in json) {
final value = AlbumCountResponseDto.fromJson(row); final value = AlbumStatisticsResponseDto.fromJson(row);
if (value != null) { if (value != null) {
result.add(value); result.add(value);
} }
@ -77,12 +77,12 @@ class AlbumCountResponseDto {
return result.toList(growable: growable); return result.toList(growable: growable);
} }
static Map<String, AlbumCountResponseDto> mapFromJson(dynamic json) { static Map<String, AlbumStatisticsResponseDto> mapFromJson(dynamic json) {
final map = <String, AlbumCountResponseDto>{}; final map = <String, AlbumStatisticsResponseDto>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) { for (final entry in json.entries) {
final value = AlbumCountResponseDto.fromJson(entry.value); final value = AlbumStatisticsResponseDto.fromJson(entry.value);
if (value != null) { if (value != null) {
map[entry.key] = value; map[entry.key] = value;
} }
@ -91,14 +91,14 @@ class AlbumCountResponseDto {
return map; return map;
} }
// maps a json object with a list of AlbumCountResponseDto-objects as value to a dart map // maps a json object with a list of AlbumStatisticsResponseDto-objects as value to a dart map
static Map<String, List<AlbumCountResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) { static Map<String, List<AlbumStatisticsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<AlbumCountResponseDto>>{}; final map = <String, List<AlbumStatisticsResponseDto>>{};
if (json is Map && json.isNotEmpty) { if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments // ignore: parameter_assignments
json = json.cast<String, dynamic>(); json = json.cast<String, dynamic>();
for (final entry in json.entries) { for (final entry in json.entries) {
map[entry.key] = AlbumCountResponseDto.listFromJson(entry.value, growable: growable,); map[entry.key] = AlbumStatisticsResponseDto.listFromJson(entry.value, growable: growable,);
} }
} }
return map; return map;

View File

@ -660,16 +660,16 @@
] ]
} }
}, },
"/albums/count": { "/albums/statistics": {
"get": { "get": {
"operationId": "getAlbumCount", "operationId": "getAlbumStatistics",
"parameters": [], "parameters": [],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/AlbumCountResponseDto" "$ref": "#/components/schemas/AlbumStatisticsResponseDto"
} }
} }
}, },
@ -7505,25 +7505,6 @@
], ],
"type": "object" "type": "object"
}, },
"AlbumCountResponseDto": {
"properties": {
"notShared": {
"type": "integer"
},
"owned": {
"type": "integer"
},
"shared": {
"type": "integer"
}
},
"required": [
"notShared",
"owned",
"shared"
],
"type": "object"
},
"AlbumResponseDto": { "AlbumResponseDto": {
"properties": { "properties": {
"albumName": { "albumName": {
@ -7611,6 +7592,25 @@
], ],
"type": "object" "type": "object"
}, },
"AlbumStatisticsResponseDto": {
"properties": {
"notShared": {
"type": "integer"
},
"owned": {
"type": "integer"
},
"shared": {
"type": "integer"
}
},
"required": [
"notShared",
"owned",
"shared"
],
"type": "object"
},
"AlbumUserAddDto": { "AlbumUserAddDto": {
"properties": { "properties": {
"role": { "role": {

View File

@ -268,7 +268,7 @@ export type CreateAlbumDto = {
assetIds?: string[]; assetIds?: string[];
description?: string; description?: string;
}; };
export type AlbumCountResponseDto = { export type AlbumStatisticsResponseDto = {
notShared: number; notShared: number;
owned: number; owned: number;
shared: number; shared: number;
@ -1369,11 +1369,11 @@ export function createAlbum({ createAlbumDto }: {
body: createAlbumDto body: createAlbumDto
}))); })));
} }
export function getAlbumCount(opts?: Oazapfts.RequestOpts) { export function getAlbumStatistics(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
data: AlbumCountResponseDto; data: AlbumStatisticsResponseDto;
}>("/albums/count", { }>("/albums/statistics", {
...opts ...opts
})); }));
} }

View File

@ -2,9 +2,9 @@ import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { import {
AddUsersDto, AddUsersDto,
AlbumCountResponseDto,
AlbumInfoDto, AlbumInfoDto,
AlbumResponseDto, AlbumResponseDto,
AlbumStatisticsResponseDto,
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
UpdateAlbumDto, UpdateAlbumDto,
@ -22,12 +22,6 @@ import { ParseMeUUIDPipe, UUIDParamDto } from 'src/validation';
export class AlbumController { export class AlbumController {
constructor(private service: AlbumService) {} constructor(private service: AlbumService) {}
@Get('count')
@Authenticated({ permission: Permission.ALBUM_STATISTICS })
getAlbumCount(@Auth() auth: AuthDto): Promise<AlbumCountResponseDto> {
return this.service.getCount(auth);
}
@Get() @Get()
@Authenticated({ permission: Permission.ALBUM_READ }) @Authenticated({ permission: Permission.ALBUM_READ })
getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> { getAllAlbums(@Auth() auth: AuthDto, @Query() query: GetAlbumsDto): Promise<AlbumResponseDto[]> {
@ -40,6 +34,12 @@ export class AlbumController {
return this.service.create(auth, dto); return this.service.create(auth, dto);
} }
@Get('statistics')
@Authenticated({ permission: Permission.ALBUM_STATISTICS })
getAlbumStatistics(@Auth() auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
return this.service.getStatistics(auth);
}
@Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true }) @Authenticated({ permission: Permission.ALBUM_READ, sharedLink: true })
@Get(':id') @Get(':id')
getAlbumInfo( getAlbumInfo(

View File

@ -95,7 +95,7 @@ export class GetAlbumsDto {
assetId?: string; assetId?: string;
} }
export class AlbumCountResponseDto { export class AlbumStatisticsResponseDto {
@ApiProperty({ type: 'integer' }) @ApiProperty({ type: 'integer' })
owned!: number; owned!: number;

View File

@ -43,12 +43,12 @@ describe(AlbumService.name, () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
describe('getCount', () => { describe('getStatistics', () => {
it('should get the album count', async () => { it('should get the album count', async () => {
albumMock.getOwned.mockResolvedValue([]); albumMock.getOwned.mockResolvedValue([]);
albumMock.getShared.mockResolvedValue([]); albumMock.getShared.mockResolvedValue([]);
albumMock.getNotShared.mockResolvedValue([]); albumMock.getNotShared.mockResolvedValue([]);
await expect(sut.getCount(authStub.admin)).resolves.toEqual({ await expect(sut.getStatistics(authStub.admin)).resolves.toEqual({
owned: 0, owned: 0,
shared: 0, shared: 0,
notShared: 0, notShared: 0,

View File

@ -1,9 +1,9 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { import {
AddUsersDto, AddUsersDto,
AlbumCountResponseDto,
AlbumInfoDto, AlbumInfoDto,
AlbumResponseDto, AlbumResponseDto,
AlbumStatisticsResponseDto,
CreateAlbumDto, CreateAlbumDto,
GetAlbumsDto, GetAlbumsDto,
UpdateAlbumDto, UpdateAlbumDto,
@ -37,7 +37,7 @@ export class AlbumService {
@Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository, @Inject(IAlbumUserRepository) private albumUserRepository: IAlbumUserRepository,
) {} ) {}
async getCount(auth: AuthDto): Promise<AlbumCountResponseDto> { async getStatistics(auth: AuthDto): Promise<AlbumStatisticsResponseDto> {
const [owned, shared, notShared] = await Promise.all([ const [owned, shared, notShared] = await Promise.all([
this.albumRepository.getOwned(auth.user.id), this.albumRepository.getOwned(auth.user.id),
this.albumRepository.getShared(auth.user.id), this.albumRepository.getShared(auth.user.id),

View File

@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { type AlbumCountResponseDto, getAlbumCount } from '@immich/sdk'; import { type AlbumStatisticsResponseDto, getAlbumStatistics } from '@immich/sdk';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte'; import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
export let albumCountType: keyof AlbumCountResponseDto; export let albumType: keyof AlbumStatisticsResponseDto;
const handleAlbumCount = async () => { const handleAlbumCount = async () => {
try { try {
return await getAlbumCount(); return await getAlbumStatistics();
} catch { } catch {
return { owned: 0, shared: 0, notShared: 0 }; return { owned: 0, shared: 0, notShared: 0 };
} }
@ -18,6 +18,6 @@
<LoadingSpinner /> <LoadingSpinner />
{:then data} {:then data}
<div> <div>
<p>{$t('albums_count', { values: { count: data[albumCountType] } })}</p> <p>{$t('albums_count', { values: { count: data[albumType] } })}</p>
</div> </div>
{/await} {/await}

View File

@ -79,7 +79,7 @@
bind:isSelected={isSharingSelected} bind:isSelected={isSharingSelected}
> >
<svelte:fragment slot="moreInformation"> <svelte:fragment slot="moreInformation">
<MoreInformationAlbums albumCountType="shared" /> <MoreInformationAlbums albumType="shared" />
</svelte:fragment> </svelte:fragment>
</SideBarLink> </SideBarLink>
{/if} {/if}
@ -100,7 +100,7 @@
</SideBarLink> </SideBarLink>
<SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo> <SideBarLink title={$t('albums')} routeId="/(user)/albums" icon={mdiImageAlbum} flippedLogo>
<svelte:fragment slot="moreInformation"> <svelte:fragment slot="moreInformation">
<MoreInformationAlbums albumCountType="owned" /> <MoreInformationAlbums albumType="owned" />
</svelte:fragment> </svelte:fragment>
</SideBarLink> </SideBarLink>