1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor: system metadata (#8923)

refactor(server): system metadata
This commit is contained in:
Jason Rasmussen 2024-04-19 20:36:15 -04:00 committed by GitHub
parent 78c7ff855d
commit 171b6bb0a6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 362 additions and 73 deletions

View File

@ -1,4 +1,4 @@
import { LoginResponseDto, getServerConfig } from '@immich/sdk';
import { LoginResponseDto } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
@ -162,19 +162,4 @@ describe('/server-info', () => {
});
});
});
describe('POST /server-info/admin-onboarding', () => {
it('should set admin onboarding', async () => {
const config = await getServerConfig({});
expect(config.isOnboarded).toBe(false);
const { status } = await request(app)
.post('/server-info/admin-onboarding')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const newConfig = await getServerConfig({});
expect(newConfig.isOnboarded).toBe(true);
});
});
});

View File

@ -0,0 +1,76 @@
import { LoginResponseDto, getServerConfig } from '@immich/sdk';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
describe('/server-info', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
await utils.resetDatabase();
admin = await utils.adminSetup({ onboarding: false });
nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1);
});
describe('POST /system-metadata/admin-onboarding', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).post('/system-metadata/admin-onboarding').send({ isOnboarded: true });
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.post('/system-metadata/admin-onboarding')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ isOnboarded: true });
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should set admin onboarding', async () => {
const config = await getServerConfig({});
expect(config.isOnboarded).toBe(false);
const { status } = await request(app)
.post('/system-metadata/admin-onboarding')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ isOnboarded: true });
expect(status).toBe(204);
const newConfig = await getServerConfig({});
expect(newConfig.isOnboarded).toBe(true);
});
});
describe('GET /system-metadata/reverse-geocoding-state', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/system-metadata/reverse-geocoding-state');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.get('/system-metadata/reverse-geocoding-state')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should get the reverse geocoding state', async () => {
const { status, body } = await request(app)
.get('/system-metadata/reverse-geocoding-state')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
lastUpdate: expect.any(String),
lastImportFileName: 'cities500.txt',
});
});
});
});

View File

@ -24,8 +24,8 @@ import {
getConfigDefaults,
login,
searchMetadata,
setAdminOnboarding,
signUpAdmin,
updateAdminOnboarding,
updateConfig,
validate,
} from '@immich/sdk';
@ -264,7 +264,10 @@ export const utils = {
await signUpAdmin({ signUpDto: signupDto.admin });
const response = await login({ loginCredentialDto: loginDto.admin });
if (options.onboarding) {
await setAdminOnboarding({ headers: asBearerAuth(response.accessToken) });
await updateAdminOnboarding(
{ adminOnboardingUpdateDto: { isOnboarded: true } },
{ headers: asBearerAuth(response.accessToken) },
);
}
return response;
},

View File

@ -13,6 +13,7 @@ doc/ActivityCreateDto.md
doc/ActivityResponseDto.md
doc/ActivityStatisticsResponseDto.md
doc/AddUsersDto.md
doc/AdminOnboardingUpdateDto.md
doc/AlbumApi.md
doc/AlbumCountResponseDto.md
doc/AlbumResponseDto.md
@ -123,6 +124,7 @@ doc/QueueStatusDto.md
doc/ReactionLevel.md
doc/ReactionType.md
doc/RecognitionConfig.md
doc/ReverseGeocodingStateResponseDto.md
doc/ScanLibraryDto.md
doc/SearchAlbumResponseDto.md
doc/SearchApi.md
@ -174,6 +176,7 @@ doc/SystemConfigTemplateStorageOptionDto.md
doc/SystemConfigThemeDto.md
doc/SystemConfigTrashDto.md
doc/SystemConfigUserDto.md
doc/SystemMetadataApi.md
doc/TagApi.md
doc/TagResponseDto.md
doc/TagTypeEnum.md
@ -226,6 +229,7 @@ lib/api/sessions_api.dart
lib/api/shared_link_api.dart
lib/api/sync_api.dart
lib/api/system_config_api.dart
lib/api/system_metadata_api.dart
lib/api/tag_api.dart
lib/api/timeline_api.dart
lib/api/trash_api.dart
@ -242,6 +246,7 @@ lib/model/activity_create_dto.dart
lib/model/activity_response_dto.dart
lib/model/activity_statistics_response_dto.dart
lib/model/add_users_dto.dart
lib/model/admin_onboarding_update_dto.dart
lib/model/album_count_response_dto.dart
lib/model/album_response_dto.dart
lib/model/all_job_status_response_dto.dart
@ -343,6 +348,7 @@ lib/model/queue_status_dto.dart
lib/model/reaction_level.dart
lib/model/reaction_type.dart
lib/model/recognition_config.dart
lib/model/reverse_geocoding_state_response_dto.dart
lib/model/scan_library_dto.dart
lib/model/search_album_response_dto.dart
lib/model/search_asset_response_dto.dart
@ -419,6 +425,7 @@ test/activity_create_dto_test.dart
test/activity_response_dto_test.dart
test/activity_statistics_response_dto_test.dart
test/add_users_dto_test.dart
test/admin_onboarding_update_dto_test.dart
test/album_api_test.dart
test/album_count_response_dto_test.dart
test/album_response_dto_test.dart
@ -534,6 +541,7 @@ test/queue_status_dto_test.dart
test/reaction_level_test.dart
test/reaction_type_test.dart
test/recognition_config_test.dart
test/reverse_geocoding_state_response_dto_test.dart
test/scan_library_dto_test.dart
test/search_album_response_dto_test.dart
test/search_api_test.dart
@ -585,6 +593,7 @@ test/system_config_template_storage_option_dto_test.dart
test/system_config_theme_dto_test.dart
test/system_config_trash_dto_test.dart
test/system_config_user_dto_test.dart
test/system_metadata_api_test.dart
test/tag_api_test.dart
test/tag_response_dto_test.dart
test/tag_type_enum_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/SystemMetadataApi.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4908,31 +4908,6 @@
]
}
},
"/server-info/admin-onboarding": {
"post": {
"operationId": "setAdminOnboarding",
"parameters": [],
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Server Info"
]
}
},
"/server-info/config": {
"get": {
"operationId": "getServerConfig",
@ -5885,6 +5860,103 @@
]
}
},
"/system-metadata/admin-onboarding": {
"get": {
"operationId": "getAdminOnboarding",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminOnboardingUpdateDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"System Metadata"
]
},
"post": {
"operationId": "updateAdminOnboarding",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AdminOnboardingUpdateDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"System Metadata"
]
}
},
"/system-metadata/reverse-geocoding-state": {
"get": {
"operationId": "getReverseGeocodingState",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReverseGeocodingStateResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"System Metadata"
]
}
},
"/tag": {
"get": {
"operationId": "getAllTags",
@ -7180,6 +7252,17 @@
],
"type": "object"
},
"AdminOnboardingUpdateDto": {
"properties": {
"isOnboarded": {
"type": "boolean"
}
},
"required": [
"isOnboarded"
],
"type": "object"
},
"AlbumCountResponseDto": {
"properties": {
"notShared": {
@ -9618,6 +9701,23 @@
],
"type": "object"
},
"ReverseGeocodingStateResponseDto": {
"properties": {
"lastImportFileName": {
"nullable": true,
"type": "string"
},
"lastUpdate": {
"nullable": true,
"type": "string"
}
},
"required": [
"lastImportFileName",
"lastUpdate"
],
"type": "object"
},
"ScanLibraryDto": {
"properties": {
"refreshAllFiles": {

View File

@ -998,6 +998,13 @@ export type SystemConfigTemplateStorageOptionDto = {
weekOptions: string[];
yearOptions: string[];
};
export type AdminOnboardingUpdateDto = {
isOnboarded: boolean;
};
export type ReverseGeocodingStateResponseDto = {
lastImportFileName: string | null;
lastUpdate: string | null;
};
export type CreateTagDto = {
name: string;
"type": TagTypeEnum;
@ -2330,12 +2337,6 @@ export function getServerInfo(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function setAdminOnboarding(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/server-info/admin-onboarding", {
...opts,
method: "POST"
}));
}
export function getServerConfig(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@ -2597,6 +2598,31 @@ export function getStorageTemplateOptions(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function getAdminOnboarding(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AdminOnboardingUpdateDto;
}>("/system-metadata/admin-onboarding", {
...opts
}));
}
export function updateAdminOnboarding({ adminOnboardingUpdateDto }: {
adminOnboardingUpdateDto: AdminOnboardingUpdateDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/system-metadata/admin-onboarding", oazapfts.json({
...opts,
method: "POST",
body: adminOnboardingUpdateDto
})));
}
export function getReverseGeocodingState(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ReverseGeocodingStateResponseDto;
}>("/system-metadata/reverse-geocoding-state", {
...opts
}));
}
export function getAllTags(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View File

@ -21,6 +21,7 @@ import { SessionController } from 'src/controllers/session.controller';
import { SharedLinkController } from 'src/controllers/shared-link.controller';
import { SyncController } from 'src/controllers/sync.controller';
import { SystemConfigController } from 'src/controllers/system-config.controller';
import { SystemMetadataController } from 'src/controllers/system-metadata.controller';
import { TagController } from 'src/controllers/tag.controller';
import { TimelineController } from 'src/controllers/timeline.controller';
import { TrashController } from 'src/controllers/trash.controller';
@ -51,6 +52,7 @@ export const controllers = [
SharedLinkController,
SyncController,
SystemConfigController,
SystemMetadataController,
TagController,
TimelineController,
TrashController,

View File

@ -1,4 +1,4 @@
import { Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { Controller, Get } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import {
ServerConfigDto,
@ -65,11 +65,4 @@ export class ServerInfoController {
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return this.service.getSupportedMediaTypes();
}
@AdminRoute()
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
setAdminOnboarding(): Promise<void> {
return this.service.setAdminOnboarding();
}
}

View File

@ -0,0 +1,28 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto';
import { Authenticated } from 'src/middleware/auth.guard';
import { SystemMetadataService } from 'src/services/system-metadata.service';
@ApiTags('System Metadata')
@Controller('system-metadata')
@Authenticated({ admin: true })
export class SystemMetadataController {
constructor(private service: SystemMetadataService) {}
@Get('admin-onboarding')
getAdminOnboarding(): Promise<AdminOnboardingUpdateDto> {
return this.service.getAdminOnboarding();
}
@Post('admin-onboarding')
@HttpCode(HttpStatus.NO_CONTENT)
updateAdminOnboarding(@Body() dto: AdminOnboardingUpdateDto): Promise<void> {
return this.service.updateAdminOnboarding(dto);
}
@Get('reverse-geocoding-state')
getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
return this.service.getReverseGeocodingState();
}
}

View File

@ -0,0 +1,15 @@
import { IsBoolean } from 'class-validator';
export class AdminOnboardingUpdateDto {
@IsBoolean()
isOnboarded!: boolean;
}
export class AdminOnboardingResponseDto {
isOnboarded!: boolean;
}
export class ReverseGeocodingStateResponseDto {
lastUpdate!: string | null;
lastImportFileName!: string | null;
}

View File

@ -36,8 +36,8 @@ export class MetadataRepository implements IMetadataRepository {
this.logger.log('Initializing metadata repository');
const geodataDate = await readFile(geodataDatePath, 'utf8');
// TODO move to metadata service init
const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
if (geocodingMetadata?.lastUpdate === geodataDate) {
return;
}

View File

@ -25,6 +25,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service';
import { SyncService } from 'src/services/sync.service';
import { SystemConfigService } from 'src/services/system-config.service';
import { SystemMetadataService } from 'src/services/system-metadata.service';
import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service';
@ -58,6 +59,7 @@ export const services = [
StorageTemplateService,
SyncService,
SystemConfigService,
SystemMetadataService,
TagService,
TimelineService,
TrashService,

View File

@ -1,5 +1,4 @@
import { serverVersion } from 'src/constants';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@ -207,13 +206,6 @@ describe(ServerInfoService.name, () => {
});
});
describe('setAdminOnboarding', () => {
it('should set admin onboarding to true', async () => {
await sut.setAdminOnboarding();
expect(systemMetadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
});
});
describe('getStats', () => {
it('should total up usage by user', async () => {
userMock.getUserStats.mockResolvedValue([

View File

@ -51,7 +51,9 @@ export class ServerInfoService {
const featureFlags = await this.getFeatures();
if (featureFlags.configFile) {
await this.setAdminOnboarding();
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
isOnboarded: true,
});
}
}
@ -105,10 +107,6 @@ export class ServerInfoService {
};
}
setAdminOnboarding(): Promise<void> {
return this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
}
async getStatistics(): Promise<ServerStatsResponseDto> {
const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
const serverStats = new ServerStatsResponseDto();

View File

@ -0,0 +1,31 @@
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemMetadataService } from 'src/services/system-metadata.service';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
describe(SystemMetadataService.name, () => {
let sut: SystemMetadataService;
let metadataMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
metadataMock = newSystemMetadataRepositoryMock();
sut = new SystemMetadataService(metadataMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('updateAdminOnboarding', () => {
it('should update isOnboarded to true', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: true })).resolves.toBeUndefined();
expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: true });
});
it('should update isOnboarded to false', async () => {
await expect(sut.updateAdminOnboarding({ isOnboarded: false })).resolves.toBeUndefined();
expect(metadataMock.set).toHaveBeenCalledWith(SystemMetadataKey.ADMIN_ONBOARDING, { isOnboarded: false });
});
});
});

View File

@ -0,0 +1,29 @@
import { Inject, Injectable } from '@nestjs/common';
import {
AdminOnboardingResponseDto,
AdminOnboardingUpdateDto,
ReverseGeocodingStateResponseDto,
} from 'src/dtos/system-metadata.dto';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@Injectable()
export class SystemMetadataService {
constructor(@Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository) {}
async getAdminOnboarding(): Promise<AdminOnboardingResponseDto> {
const value = await this.repository.get(SystemMetadataKey.ADMIN_ONBOARDING);
return { isOnboarded: false, ...value };
}
async updateAdminOnboarding(dto: AdminOnboardingUpdateDto): Promise<void> {
await this.repository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
isOnboarded: dto.isOnboarded,
});
}
async getReverseGeocodingState(): Promise<ReverseGeocodingStateResponseDto> {
const value = await this.repository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE);
return { lastUpdate: null, lastImportFileName: null, ...value };
}
}

View File

@ -5,7 +5,7 @@
import OnboadingStorageTemplate from '$lib/components/onboarding-page/onboarding-storage-template.svelte';
import OnboardingTheme from '$lib/components/onboarding-page/onboarding-theme.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { setAdminOnboarding } from '@immich/sdk';
import { updateAdminOnboarding } from '@immich/sdk';
let index = 0;
@ -28,7 +28,7 @@
const handleDoneClicked = async () => {
if (index >= onboardingSteps.length - 1) {
await setAdminOnboarding();
await updateAdminOnboarding({ adminOnboardingUpdateDto: { isOnboarded: true } });
await goto(AppRoute.PHOTOS);
} else {
index++;