Feature/add market data tab to admin control panel (#497)

* Add market data tab

* Update changelog
pull/498/head
Thomas Kaul 3 years ago committed by GitHub
parent fcf07a0fd1
commit 6c07759eb7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added tabs with routing to the admin control panel
- Added a new tab to manage historical data to the admin control panel
### Changed

@ -1,5 +1,9 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
} from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
@ -11,6 +15,7 @@ import {
Get,
HttpException,
Inject,
Param,
Post,
UseGuards
} from '@nestjs/common';
@ -86,4 +91,42 @@ export class AdminController {
return;
}
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(): Promise<AdminMarketData> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketData();
}
@Get('market-data/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('symbol') symbol
): Promise<AdminMarketDataDetails> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol(symbol);
}
}

@ -3,6 +3,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
@ -15,6 +16,7 @@ import { AdminService } from './admin.service';
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
SubscriptionModule
],

@ -2,9 +2,14 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { AdminData } from '@ghostfolio/common/interfaces';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { differenceInDays } from 'date-fns';
@ -14,6 +19,7 @@ export class AdminService {
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {}
@ -45,6 +51,31 @@ export class AdminService {
};
}
public async getMarketData(): Promise<AdminMarketData> {
return {
marketData: await (
await this.dataGatheringService.getSymbolsMax()
).map((symbol) => {
return symbol;
})
};
}
public async getMarketDataBySymbol(
aSymbol: string
): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
symbol: aSymbol
}
})
};
}
private async getLastDataGathering() {
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();

@ -1,11 +1,11 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
jest.mock('./market-data.service', () => {
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
MarketDataService: jest.fn().mockImplementation(() => {
return {

@ -1,5 +1,6 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
@ -8,7 +9,6 @@ import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
import { MarketDataService } from './market-data.service';
@Injectable()
export class CurrentRateService {

@ -7,12 +7,12 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@ -26,6 +26,7 @@ import { RulesService } from './rules.service';
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PrismaModule,
SymbolProfileModule,
@ -35,7 +36,6 @@ import { RulesService } from './rules.service';
providers: [
AccountService,
CurrentRateService,
MarketDataService,
PortfolioService,
RulesService
]

@ -313,6 +313,52 @@ export class DataGatheringService {
return undefined;
}
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
}
public async reset() {
Logger.log('Data gathering has been reset.');
@ -379,52 +425,6 @@ export class DataGatheringService {
];
}
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate =
(
await this.prismaService.order.findFirst({
orderBy: [{ date: 'asc' }]
})
)?.date ?? new Date();
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
return {
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [
...this.getBenchmarksToGather(startDate),
...currencyPairsToGather,
...symbolProfilesToGather
];
}
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);

@ -0,0 +1,11 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { MarketDataService } from './market-data.service';
@Module({
exports: [MarketDataService],
imports: [PrismaModule],
providers: [MarketDataService]
})
export class MarketDataModule {}

@ -1,9 +1,8 @@
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client';
import { DateQuery } from './interfaces/date-query.interface';
import { MarketData, Prisma } from '@prisma/client';
@Injectable()
export class MarketDataService {
@ -48,4 +47,22 @@ export class MarketDataService {
}
});
}
public async marketDataItems(params: {
skip?: number;
take?: number;
cursor?: Prisma.MarketDataWhereUniqueInput;
where?: Prisma.MarketDataWhereInput;
orderBy?: Prisma.MarketDataOrderByInput;
}): Promise<MarketData[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.marketData.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
}

@ -0,0 +1,18 @@
<div>
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
<div>{{ itemByMonth.key }}</div>
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
<div
*ngFor="let dayItem of days; let i = index"
class="day"
[title]="
(marketDataByMonth[itemByMonth.key][i + 1]?.date
| date: defaultDateFormat) ?? ''
"
[ngClass]="{
available: marketDataByMonth[itemByMonth.key][i + 1]?.day == i + 1
}"
></div>
</div>
</div>
</div>

@ -0,0 +1,16 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
.day {
background-color: var(--danger);
height: 0.5rem;
margin-right: 0.25rem;
width: 0.5rem;
&.available {
background-color: var(--success);
}
}
}

@ -0,0 +1,48 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { MarketData } from '@prisma/client';
import { format } from 'date-fns';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-market-data-detail',
styleUrls: ['./admin-market-data-detail.component.scss'],
templateUrl: './admin-market-data-detail.component.html'
})
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() marketData: MarketData[];
public days = Array(31);
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public marketDataByMonth: {
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
} = {};
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
this.marketDataByMonth = {};
for (const marketDataItem of this.marketData) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM');
if (!this.marketDataByMonth[key]) {
this.marketDataByMonth[key] = {};
}
this.marketDataByMonth[key][currentDay] = {
...marketDataItem,
day: currentDay
};
}
}
}

@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
@NgModule({
declarations: [AdminMarketDataDetailComponent],
exports: [AdminMarketDataDetailComponent],
imports: [CommonModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataDetailModule {}

@ -0,0 +1,82 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnDestroy,
OnInit
} from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { MarketData } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-admin-market-data',
styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html'
})
export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = [];
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.fetchAdminMarketData();
}
public setCurrentSymbol(aSymbol: string) {
this.marketDataDetails = [];
if (this.currentSymbol === aSymbol) {
this.currentSymbol = '';
} else {
this.currentSymbol = aSymbol;
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchAdminMarketData() {
this.dataService
.fetchAdminMarketData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketData = marketData;
this.changeDetectorRef.markForCheck();
});
}
private fetchAdminMarketDataBySymbol(aSymbol: string) {
this.dataService
.fetchAdminMarketDataBySymbol(aSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;
this.changeDetectorRef.markForCheck();
});
}
}

@ -0,0 +1,37 @@
<div class="container">
<div class="row">
<div class="col">
<table class="gf-table w-100">
<thead>
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>First transaction</th>
</tr>
</thead>
<tbody>
<ng-container *ngFor="let item of marketData; let i = index">
<tr
class="cursor-pointer mat-row"
(click)="setCurrentSymbol(item.symbol)"
>
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }}
</td>
</tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td></td>
<td colspan="2">
<gf-admin-market-data-detail
[marketData]="marketDataDetails"
></gf-admin-market-data-detail>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
</div>

@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataComponent } from './admin-market-data.component';
@NgModule({
declarations: [AdminMarketDataComponent],
imports: [CommonModule, GfAdminMarketDataDetailModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataModule {}

@ -0,0 +1,5 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
}

@ -1,5 +1,6 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdminMarketDataComponent } from '@ghostfolio/client/components/admin-market-data/admin-market-data.component';
import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-overview/admin-overview.component';
import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
@ -13,6 +14,7 @@ const routes: Routes = [
canActivate: [AuthGuard],
children: [
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
{ path: 'market-data', component: AdminMarketDataComponent },
{ path: 'overview', component: AdminOverviewComponent },
{ path: 'users', component: AdminUsersComponent }
]

@ -4,7 +4,8 @@
<a
*ngFor="let link of [
{ iconName: 'reader-outline', path: 'overview' },
{ iconName: 'people-outline', path: 'users' }
{ iconName: 'people-outline', path: 'users' },
{ iconName: 'server-outline', path: 'market-data' }
]"
#rla="routerLinkActive"
mat-tab-link

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { MatTabsModule } from '@angular/material/tabs';
import { GfAdminMarketDataModule } from '@ghostfolio/client/components/admin-market-data/admin-market-data.module';
import { GfAdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
import { GfAdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
import { CacheService } from '@ghostfolio/client/services/cache.service';
@ -18,6 +19,7 @@ import { AdminPageComponent } from './admin-page.component';
imports: [
AdminPageRoutingModule,
CommonModule,
GfAdminMarketDataModule,
GfAdminOverviewModule,
GfAdminUsersModule,
GfValueModule,

@ -11,6 +11,7 @@
padding-bottom: constant(safe-area-inset-bottom);
::ng-deep {
gf-admin-market-data,
gf-admin-overview,
gf-admin-users {
flex: 1 1 auto;

@ -16,6 +16,8 @@ import {
Access,
Accounts,
AdminData,
AdminMarketData,
AdminMarketDataDetails,
Export,
InfoItem,
PortfolioChart,
@ -64,6 +66,23 @@ export class DataService {
return this.http.get<AdminData>('/api/admin');
}
public fetchAdminMarketData() {
return this.http.get<AdminMarketData>('/api/admin/market-data');
}
public fetchAdminMarketDataBySymbol(
aSymbol: string
): Observable<AdminMarketDataDetails> {
return this.http.get<any>(`/api/admin/market-data/${aSymbol}`).pipe(
map((data) => {
for (const item of data.marketData) {
item.date = parseISO(item.date);
}
return data;
})
);
}
public deleteAccess(aId: string) {
return this.http.delete<any>(`/api/access/${aId}`);
}

@ -134,6 +134,10 @@ ngx-skeleton-loader {
}
}
.cursor-pointer {
cursor: pointer;
}
.gf-table {
@include gf-table;
}

@ -0,0 +1,5 @@
import { MarketData } from '@prisma/client';
export interface AdminMarketDataDetails {
marketData: MarketData[];
}

@ -0,0 +1,7 @@
export interface AdminMarketData {
marketData: AdminMarketDataItem[];
}
export interface AdminMarketDataItem {
symbol: string;
}

@ -1,6 +1,8 @@
import { Access } from './access.interface';
import { Accounts } from './accounts.interface';
import { AdminData } from './admin-data.interface';
import { AdminMarketDataDetails } from './admin-market-data-details.interface';
import { AdminMarketData } from './admin-market-data.interface';
import { Export } from './export.interface';
import { InfoItem } from './info-item.interface';
import { PortfolioChart } from './portfolio-chart.interface';
@ -23,6 +25,8 @@ export {
Access,
Accounts,
AdminData,
AdminMarketData,
AdminMarketDataDetails,
Export,
InfoItem,
PortfolioChart,

Loading…
Cancel
Save