Browse Source

Feature/extend data providers management of admin control panel (#4615)

* Extend data providers management

* Update changelog
pull/4622/head
Thomas Kaul 2 days ago
committed by GitHub
parent
commit
b90bfc3d6e
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 2
      apps/api/src/app/admin/admin.controller.ts
  3. 11
      apps/api/src/app/admin/admin.service.ts
  4. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  5. 1
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  6. 1
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  7. 5
      apps/api/src/services/data-provider/data-provider.service.ts
  8. 1
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  9. 1
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  10. 3
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  11. 1
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  12. 1
      apps/api/src/services/data-provider/manual/manual.service.ts
  13. 1
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  14. 1
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  15. 152
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  16. 49
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  17. 2
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  18. 26
      apps/client/src/app/services/admin.service.ts
  19. 3
      libs/common/src/lib/interfaces/admin-data.interface.ts
  20. 3
      libs/common/src/lib/interfaces/data-provider-info.interface.ts

1
CHANGELOG.md

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Changed the column label from _Index_ to _Name_ in the benchmark component - Changed the column label from _Index_ to _Name_ in the benchmark component
- Extended the data providers management of the admin control panel
- Improved the language localization for German (`de`) - Improved the language localization for German (`de`)
## 2.156.0 - 2025-04-27 ## 2.156.0 - 2025-04-27

2
apps/api/src/app/admin/admin.controller.ts

@ -68,7 +68,7 @@ export class AdminController {
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAdminData(): Promise<AdminData> { public async getAdminData(): Promise<AdminData> {
return this.adminService.get(); return this.adminService.get({ user: this.request.user });
} }
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)

11
apps/api/src/app/admin/admin.service.ts

@ -29,7 +29,7 @@ import {
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset, UserWithSettings } from '@ghostfolio/common/types';
import { import {
BadRequestException, BadRequestException,
@ -134,7 +134,9 @@ export class AdminService {
} }
} }
public async get(): Promise<AdminData> { public async get({ user }: { user: UserWithSettings }): Promise<AdminData> {
const dataSources = await this.dataProviderService.getDataSources({ user });
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
this.prismaService.order.count(), this.prismaService.order.count(),
@ -145,6 +147,11 @@ export class AdminService {
settings, settings,
transactionCount, transactionCount,
userCount, userCount,
dataProviders: dataSources.map((dataSource) => {
return this.dataProviderService
.getDataProvider(dataSource)
.getDataProviderInfo();
}),
version: environment.version version: environment.version
}; };
} }

10
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { import {
GetAssetProfileParams, GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
@ -327,10 +328,15 @@ export class GhostfolioService {
} }
private getDataProviderInfo(): DataProviderInfo { private getDataProviderInfo(): DataProviderInfo {
const ghostfolioDataProviderService = new GhostfolioDataProviderService(
this.configurationService,
this.propertyService
);
return { return {
...ghostfolioDataProviderService.getDataProviderInfo(),
isPremium: false, isPremium: false,
name: 'Ghostfolio Premium', name: 'Ghostfolio Premium'
url: 'https://ghostfol.io'
}; };
} }

1
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -52,6 +52,7 @@ export class AlphaVantageService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.ALPHA_VANTAGE,
isPremium: false, isPremium: false,
name: 'Alpha Vantage', name: 'Alpha Vantage',
url: 'https://www.alphavantage.co' url: 'https://www.alphavantage.co'

1
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -92,6 +92,7 @@ export class CoinGeckoService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.COINGECKO,
isPremium: false, isPremium: false,
name: 'CoinGecko', name: 'CoinGecko',
url: 'https://coingecko.com' url: 'https://coingecko.com'

5
apps/api/src/services/data-provider/data-provider.service.ts

@ -26,6 +26,7 @@ import {
LookupItem, LookupItem,
LookupResponse LookupResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasRole } from '@ghostfolio/common/permissions';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
@ -169,6 +170,7 @@ export class DataProviderService {
let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES'; let dataSourcesKey: 'DATA_SOURCES' | 'DATA_SOURCES_LEGACY' = 'DATA_SOURCES';
if ( if (
!hasRole(user, 'ADMIN') &&
isBefore(user.createdAt, new Date('2025-03-23')) && isBefore(user.createdAt, new Date('2025-03-23')) &&
this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0 this.configurationService.get('DATA_SOURCES_LEGACY')?.length > 0
) { ) {
@ -185,7 +187,7 @@ export class DataProviderService {
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
)) as string; )) as string;
if (ghostfolioApiKey) { if (ghostfolioApiKey || hasRole(user, 'ADMIN')) {
dataSources.push('GHOSTFOLIO'); dataSources.push('GHOSTFOLIO');
} }
@ -670,6 +672,7 @@ export class DataProviderService {
lookupItem.dataProviderInfo.isPremium = false; lookupItem.dataProviderInfo.isPremium = false;
} }
lookupItem.dataProviderInfo.dataSource = undefined;
lookupItem.dataProviderInfo.name = undefined; lookupItem.dataProviderInfo.name = undefined;
lookupItem.dataProviderInfo.url = undefined; lookupItem.dataProviderInfo.url = undefined;
} else { } else {

1
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -68,6 +68,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.EOD_HISTORICAL_DATA,
isPremium: true, isPremium: true,
name: 'EOD Historical Data', name: 'EOD Historical Data',
url: 'https://eodhd.com' url: 'https://eodhd.com'

1
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -223,6 +223,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.FINANCIAL_MODELING_PREP,
isPremium: true, isPremium: true,
name: 'Financial Modeling Prep', name: 'Financial Modeling Prep',
url: 'https://financialmodelingprep.com/developer/docs' url: 'https://financialmodelingprep.com/developer/docs'

3
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -92,9 +92,10 @@ export class GhostfolioService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.GHOSTFOLIO,
isPremium: true, isPremium: true,
name: 'Ghostfolio', name: 'Ghostfolio',
url: 'https://ghostfo.io' url: 'https://ghostfol.io'
}; };
} }

1
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -47,6 +47,7 @@ export class GoogleSheetsService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.GOOGLE_SHEETS,
isPremium: false, isPremium: false,
name: 'Google Sheets', name: 'Google Sheets',
url: 'https://docs.google.com/spreadsheets' url: 'https://docs.google.com/spreadsheets'

1
apps/api/src/services/data-provider/manual/manual.service.ts

@ -64,6 +64,7 @@ export class ManualService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.MANUAL,
isPremium: false isPremium: false
}; };
} }

1
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

@ -43,6 +43,7 @@ export class RapidApiService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.RAPID_API,
isPremium: false, isPremium: false,
name: 'Rapid API', name: 'Rapid API',
url: 'https://rapidapi.com' url: 'https://rapidapi.com'

1
apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts

@ -51,6 +51,7 @@ export class YahooFinanceService implements DataProviderInterface {
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
return { return {
dataSource: DataSource.YAHOO,
isPremium: false, isPremium: false,
name: 'Yahoo Finance', name: 'Yahoo Finance',
url: 'https://finance.yahoo.com' url: 'https://finance.yahoo.com'

152
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -4,74 +4,100 @@
<h2 class="text-center" i18n>Data Providers</h2> <h2 class="text-center" i18n>Data Providers</h2>
<mat-card appearance="outlined"> <mat-card appearance="outlined">
<mat-card-content> <mat-card-content>
<div class="align-items-center d-flex my-3"> @for (dataProvider of dataProviders; track dataProvider.name) {
<div class="w-50"> <div class="align-items-center d-flex my-3">
<a @if (dataProvider.name === 'Ghostfolio') {
class="align-items-center d-inline-flex" <div class="w-50">
target="_blank" <div class="d-flex">
[href]="pricingUrl" <gf-asset-profile-icon
> class="mr-1"
@if (isGhostfolioApiKeyValid === false) { [url]="dataProvider.url"
<span class="badge badge-warning mr-1" i18n />
>Early Access</span <div>
> <a
} class="align-items-center d-inline-flex"
Ghostfolio Premium target="_blank"
<gf-premium-indicator [href]="pricingUrl"
class="d-inline-block ml-1" >
[enableLink]="false" Ghostfolio Premium
/> <gf-premium-indicator
</a> class="d-inline-block ml-1"
@if (isGhostfolioApiKeyValid === true) { [enableLink]="false"
<div class="line-height-1"> />
<small class="text-muted"> @if (isGhostfolioApiKeyValid === false) {
<ng-container i18n>Valid until</ng-container> <span class="badge badge-warning ml-2" i18n
{{ >Early Access</span
ghostfolioApiStatus?.subscription?.expiresAt >
| date: defaultDateFormat }
}}</small </a>
> @if (isGhostfolioApiKeyValid === true) {
</div> <div class="line-height-1">
} <small class="text-muted">
</div> <ng-container i18n>Valid until</ng-container>
<div class="w-50"> {{
@if (isGhostfolioApiKeyValid === true) { ghostfolioApiStatus?.subscription?.expiresAt
<div class="align-items-center d-flex flex-wrap"> | date: defaultDateFormat
<div class="flex-grow-1 mr-3"> }}</small
{{ ghostfolioApiStatus.dailyRequests }} >
<ng-container i18n>of</ng-container> </div>
{{ ghostfolioApiStatus.dailyRequestsMax }} }
<ng-container i18n>daily requests</ng-container> </div>
</div> </div>
<button </div>
class="mx-1 no-min-width px-2" <div class="w-50">
mat-button @if (isGhostfolioApiKeyValid === true) {
[matMenuTriggerFor]="ghostfolioApiMenu" <div class="align-items-center d-flex flex-wrap">
(click)="$event.stopPropagation()" <div class="flex-grow-1 mr-3">
> {{ ghostfolioApiStatus.dailyRequests }}
<ion-icon name="ellipsis-horizontal" /> <ng-container i18n>of</ng-container>
</button> {{ ghostfolioApiStatus.dailyRequestsMax }}
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before"> <ng-container i18n>daily requests</ng-container>
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()"> </div>
<span class="align-items-center d-flex"> <button
<ion-icon class="mr-2" name="trash-outline" /> class="mx-1 no-min-width px-2"
<span i18n>Remove API key</span> mat-button
</span> [matMenuTriggerFor]="ghostfolioApiMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before">
<button
mat-menu-item
(click)="onRemoveGhostfolioApiKey()"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Remove API key</span>
</span>
</button>
</mat-menu>
</div>
} @else if (isGhostfolioApiKeyValid === false) {
<button
color="accent"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API key</span>
</button> </button>
</mat-menu> }
</div>
} @else {
<div class="w-50">
<div class="d-flex">
<gf-asset-profile-icon
class="mr-1"
[url]="dataProvider.url"
/>
{{ dataProvider.name }}
</div>
</div> </div>
} @else if (isGhostfolioApiKeyValid === false) { <div class="w-50"></div>
<button
color="accent"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API key</span>
</button>
} }
</div> </div>
</div> }
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

49
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -10,6 +10,7 @@ import {
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DataProviderInfo,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -35,6 +36,7 @@ import { GhostfolioPremiumApiDialogParams } from './ghostfolio-premium-api-dialo
standalone: false standalone: false
}) })
export class AdminSettingsComponent implements OnDestroy, OnInit { export class AdminSettingsComponent implements OnDestroy, OnInit {
public dataProviders: DataProviderInfo[];
public defaultDateFormat: string; public defaultDateFormat: string;
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse; public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public isGhostfolioApiKeyValid: boolean; public isGhostfolioApiKeyValid: boolean;
@ -124,23 +126,36 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
private initialize() { private initialize() {
this.adminService this.adminService
.fetchGhostfolioDataProviderStatus() .fetchAdminData()
.pipe( .pipe(takeUntil(this.unsubscribeSubject))
catchError(() => { .subscribe(({ dataProviders, settings }) => {
this.isGhostfolioApiKeyValid = false; this.dataProviders = dataProviders.filter(({ dataSource }) => {
return dataSource !== 'MANUAL';
this.changeDetectorRef.markForCheck(); });
return of(null); this.adminService
}), .fetchGhostfolioDataProviderStatus(
filter((status) => { settings[PROPERTY_API_KEY_GHOSTFOLIO] as string
return status !== null; )
}), .pipe(
takeUntil(this.unsubscribeSubject) catchError(() => {
) this.isGhostfolioApiKeyValid = false;
.subscribe((status) => {
this.ghostfolioApiStatus = status; this.changeDetectorRef.markForCheck();
this.isGhostfolioApiKeyValid = true;
return of(null);
}),
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

2
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -1,5 +1,6 @@
import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module'; import { GfAdminPlatformModule } from '@ghostfolio/client/components/admin-platform/admin-platform.module';
import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module'; import { GfAdminTagModule } from '@ghostfolio/client/components/admin-tag/admin-tag.module';
import { GfAssetProfileIconComponent } from '@ghostfolio/client/components/asset-profile-icon/asset-profile-icon.component';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
@ -17,6 +18,7 @@ import { AdminSettingsComponent } from './admin-settings.component';
CommonModule, CommonModule,
GfAdminPlatformModule, GfAdminPlatformModule,
GfAdminTagModule, GfAdminTagModule,
GfAssetProfileIconComponent,
GfPremiumIndicatorComponent, GfPremiumIndicatorComponent,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,

26
apps/client/src/app/services/admin.service.ts

@ -4,8 +4,7 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { import {
HEADER_KEY_SKIP_INTERCEPTOR, HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN, HEADER_KEY_TOKEN
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config'; import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { import {
@ -24,7 +23,6 @@ import { Injectable } from '@angular/core';
import { SortDirection } from '@angular/material/sort'; import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform } from '@prisma/client'; import { DataSource, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { switchMap } from 'rxjs';
import { environment } from '../../environments/environment'; import { environment } from '../../environments/environment';
import { DataService } from './data.service'; import { DataService } from './data.service';
@ -115,19 +113,15 @@ export class AdminService {
}); });
} }
public fetchGhostfolioDataProviderStatus() { public fetchGhostfolioDataProviderStatus(aApiKey: string) {
return this.fetchAdminData().pipe( const headers = new HttpHeaders({
switchMap(({ settings }) => { [HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
const headers = new HttpHeaders({ [HEADER_KEY_TOKEN]: `Api-Key ${aApiKey}`
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true', });
[HEADER_KEY_TOKEN]: `Api-Key ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
}); return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
return this.http.get<DataProviderGhostfolioStatusResponse>( { headers }
`${environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`,
{ headers }
);
})
); );
} }

3
libs/common/src/lib/interfaces/admin-data.interface.ts

@ -1,4 +1,7 @@
import { DataProviderInfo } from './data-provider-info.interface';
export interface AdminData { export interface AdminData {
dataProviders: DataProviderInfo[];
settings: { [key: string]: boolean | object | string | string[] }; settings: { [key: string]: boolean | object | string | string[] };
transactionCount: number; transactionCount: number;
userCount: number; userCount: number;

3
libs/common/src/lib/interfaces/data-provider-info.interface.ts

@ -1,4 +1,7 @@
import { DataSource } from '@prisma/client';
export interface DataProviderInfo { export interface DataProviderInfo {
dataSource?: DataSource;
isPremium: boolean; isPremium: boolean;
name?: string; name?: string;
url?: string; url?: string;

Loading…
Cancel
Save