Feature/added ability to add asset profile in admin control panel (#2075)
* Added ability to add asset profile in admin control panel * Update changelog
This commit is contained in:
parent
b5e2a3aa91
commit
d0a4f5c000
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added an icon to the external links in the footer navigation
|
- Added an icon to the external links in the footer navigation
|
||||||
|
- Added the ability to add an asset profile in the admin control panel
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
@ -26,11 +27,12 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
Query,
|
||||||
UseGuards
|
UseGuards,
|
||||||
|
UseInterceptors
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
|
||||||
import { isDate } from 'date-fns';
|
import { isDate } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
@ -328,6 +330,28 @@ export class AdminController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||||
|
public async addProfileData(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.addAssetProfile({ dataSource, symbol });
|
||||||
|
}
|
||||||
|
|
||||||
@Delete('profile-data/:dataSource/:symbol')
|
@Delete('profile-data/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async deleteProfileData(
|
public async deleteProfileData(
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||||
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||||
@ -14,8 +15,8 @@ import {
|
|||||||
Filter,
|
Filter,
|
||||||
UniqueAsset
|
UniqueAsset
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { AssetSubClass, Prisma, Property } from '@prisma/client';
|
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
import { groupBy } from 'lodash';
|
import { groupBy } from 'lodash';
|
||||||
|
|
||||||
@ -25,6 +26,7 @@ export class AdminService {
|
|||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly marketDataService: MarketDataService,
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
@ -35,6 +37,38 @@ export class AdminService {
|
|||||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async addAssetProfile({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: UniqueAsset): Promise<SymbolProfile | never> {
|
||||||
|
try {
|
||||||
|
const assetProfiles = await this.dataProviderService.getAssetProfiles([
|
||||||
|
{ dataSource, symbol }
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!assetProfiles[symbol]?.currency) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile not found for ${symbol} (${dataSource})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await this.symbolProfileService.add(
|
||||||
|
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (
|
||||||
|
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
|
error.code === 'P2002'
|
||||||
|
) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Asset profile of ${symbol} (${dataSource}) already exists`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||||
|
@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
|
|||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
public async add(
|
||||||
|
assetProfile: Prisma.SymbolProfileCreateInput
|
||||||
|
): Promise<SymbolProfile | never> {
|
||||||
|
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
||||||
|
}
|
||||||
|
|
||||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||||
return this.prismaService.symbolProfile.delete({
|
return this.prismaService.symbolProfile.delete({
|
||||||
where: { dataSource_symbol: { dataSource, symbol } }
|
where: { dataSource_symbol: { dataSource, symbol } }
|
||||||
|
@ -24,6 +24,8 @@ import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
|
|||||||
|
|
||||||
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
|
||||||
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
|
||||||
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
|
||||||
|
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -99,6 +101,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
dataSource: params['dataSource'],
|
dataSource: params['dataSource'],
|
||||||
symbol: params['symbol']
|
symbol: params['symbol']
|
||||||
});
|
});
|
||||||
|
} else if (params['createAssetProfileDialog']) {
|
||||||
|
this.openCreateAssetProfileDialog();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -241,4 +245,52 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openCreateAssetProfileDialog() {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: <CreateAssetProfileDialogParams>{
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ dataSource, symbol }) => {
|
||||||
|
if (dataSource && symbol) {
|
||||||
|
this.adminService
|
||||||
|
.addAssetProfile({ dataSource, symbol })
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
this.isLoading = true;
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
|
||||||
|
return this.dataService.fetchAdminMarketData({
|
||||||
|
filters: this.activeFilters
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
takeUntil(this.unsubscribeSubject)
|
||||||
|
)
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.dataSource = new MatTableDataSource(marketData);
|
||||||
|
this.dataSource.sort = this.sort;
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -164,4 +164,16 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="fab-container">
|
||||||
|
<a
|
||||||
|
class="align-items-center d-flex justify-content-center"
|
||||||
|
color="primary"
|
||||||
|
mat-fab
|
||||||
|
[queryParams]="{ createAssetProfileDialog: true }"
|
||||||
|
[routerLink]="[]"
|
||||||
|
>
|
||||||
|
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -4,10 +4,12 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||||
|
|
||||||
import { AdminMarketDataComponent } from './admin-market-data.component';
|
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||||
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
|
||||||
|
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminMarketDataComponent],
|
declarations: [AdminMarketDataComponent],
|
||||||
@ -15,10 +17,12 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
GfActivitiesFilterModule,
|
GfActivitiesFilterModule,
|
||||||
GfAssetProfileDialogModule,
|
GfAssetProfileDialogModule,
|
||||||
|
GfCreateAssetProfileDialogModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatSortModule,
|
MatSortModule,
|
||||||
MatTableModule
|
MatTableModule,
|
||||||
|
RouterModule
|
||||||
],
|
],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -2,4 +2,11 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
.fab-container {
|
||||||
|
bottom: 2rem;
|
||||||
|
position: fixed;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
FormBuilder,
|
||||||
|
FormControl,
|
||||||
|
FormGroup,
|
||||||
|
Validators
|
||||||
|
} from '@angular/forms';
|
||||||
|
import { MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-create-asset-profile-dialog',
|
||||||
|
templateUrl: 'create-asset-profile-dialog.html'
|
||||||
|
})
|
||||||
|
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
|
||||||
|
public createAssetProfileForm: FormGroup;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public readonly adminService: AdminService,
|
||||||
|
public readonly changeDetectorRef: ChangeDetectorRef,
|
||||||
|
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
|
||||||
|
public readonly formBuilder: FormBuilder
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit() {
|
||||||
|
this.createAssetProfileForm = this.formBuilder.group({
|
||||||
|
searchSymbol: new FormControl(null, [Validators.required])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCancel() {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSubmit() {
|
||||||
|
this.dialogRef.close({
|
||||||
|
dataSource:
|
||||||
|
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
|
||||||
|
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
<form
|
||||||
|
class="d-flex flex-column h-100"
|
||||||
|
[formGroup]="createAssetProfileForm"
|
||||||
|
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
|
||||||
|
(ngSubmit)="onSubmit()"
|
||||||
|
>
|
||||||
|
<h1 i18n mat-dialog-title>Create Asset Profile</h1>
|
||||||
|
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Name, symbol or ISIN</mat-label>
|
||||||
|
<gf-symbol-autocomplete formControlName="searchSymbol" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||||
|
<button
|
||||||
|
color="primary"
|
||||||
|
mat-flat-button
|
||||||
|
type="submit"
|
||||||
|
[disabled]="!createAssetProfileForm.valid"
|
||||||
|
>
|
||||||
|
<ng-container i18n>Create</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,24 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
|
||||||
|
|
||||||
|
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [CreateAssetProfileDialog],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
GfSymbolAutocompleteModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfCreateAssetProfileDialogModule {}
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface CreateAssetProfileDialogParams {
|
||||||
|
deviceType: string;
|
||||||
|
locale: string;
|
||||||
|
}
|
@ -23,6 +23,13 @@ import { Observable, map } from 'rxjs';
|
|||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(private http: HttpClient) {}
|
public constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
|
public addAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||||
|
return this.http.post<void>(
|
||||||
|
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public deleteJob(aId: string) {
|
public deleteJob(aId: string) {
|
||||||
return this.http.delete<void>(`/api/v1/admin/queue/job/${aId}`);
|
return this.http.delete<void>(`/api/v1/admin/queue/job/${aId}`);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user