Browse Source

Feature/support migrating asset profile to MANUAL data source (#7376)

* Support migrating asset profile to MANUAL data source

* Update changelog
pull/7381/head
Thomas Kaul 3 days ago
committed by GitHub
parent
commit
faf01ae820
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 113
      apps/api/src/app/admin/admin.service.ts
  3. 2
      apps/api/src/services/market-data/market-data.service.ts
  4. 9
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  5. 17
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  6. 124
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  7. 13
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added support for converting an asset profile to the `MANUAL` data source in the asset profile details dialog of the admin control panel
### Changed
- Restricted the symbol data endpoint (`GET /api/v1/symbol/:dataSource/:symbol`) to authenticated users

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

@ -12,6 +12,7 @@ import {
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol
} from '@ghostfolio/common/helper';
@ -39,6 +40,7 @@ import {
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { randomUUID } from 'node:crypto';
@Injectable()
export class AdminService {
@ -240,16 +242,25 @@ export class AdminService {
url
}: Prisma.SymbolProfileUpdateInput
) {
const isConversionToManualDataSource =
newDataSource === DataSource.MANUAL && dataSource !== DataSource.MANUAL;
if (isConversionToManualDataSource && !newSymbol) {
newSymbol = randomUUID();
}
if (
newSymbol &&
newDataSource &&
(newSymbol !== symbol || newDataSource !== dataSource)
newSymbol &&
(newDataSource !== dataSource || newSymbol !== symbol)
) {
const newAssetProfileIdentifier: AssetProfileIdentifier = {
dataSource: newDataSource as DataSource,
symbol: newSymbol as string
};
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
newAssetProfileIdentifier
]);
if (assetProfile) {
@ -259,45 +270,79 @@ export class AdminService {
);
}
try {
await Promise.all([
this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
const operations: Prisma.PrismaPromise<unknown>[] = [
this.symbolProfileService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
newAssetProfileIdentifier
),
this.marketDataService.updateAssetProfileIdentifier(
{
dataSource,
symbol
},
newAssetProfileIdentifier
)
];
if (isConversionToManualDataSource) {
const currentAssetProfile =
await this.prismaService.symbolProfile.findUnique({
include: { assetProfileOverrides: true },
where: { dataSource_symbol: { dataSource, symbol } }
});
if (!currentAssetProfile) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const currentAssetProfileWithOverrides = applyAssetProfileOverrides(
currentAssetProfile,
currentAssetProfile.assetProfileOverrides
);
operations.push(
// The overrides are applied on every read, so delete them and
// persist the merged values in the asset profile instead
this.symbolProfileService.deleteAssetProfileOverrides(
newAssetProfileIdentifier
),
this.marketDataService.updateAssetProfileIdentifier(
this.symbolProfileService.updateSymbolProfile(
newAssetProfileIdentifier,
{
dataSource,
symbol
},
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
assetClass: currentAssetProfileWithOverrides.assetClass,
assetSubClass: currentAssetProfileWithOverrides.assetSubClass,
countries:
currentAssetProfileWithOverrides.countries ?? undefined,
holdings: currentAssetProfileWithOverrides.holdings ?? undefined,
name: currentAssetProfileWithOverrides.name,
sectors: currentAssetProfileWithOverrides.sectors ?? undefined,
url: currentAssetProfileWithOverrides.url
}
)
]);
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
{
dataSource: DataSource[newDataSource.toString()],
symbol: newSymbol as string
}
]);
);
}
return updatedAssetProfile;
try {
await this.prismaService.$transaction(operations);
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
const [updatedAssetProfile] =
await this.symbolProfileService.getSymbolProfiles([
newAssetProfileIdentifier
]);
return updatedAssetProfile;
} else {
const assetProfileOverrides = {
assetClass: assetClass as AssetClass,

2
apps/api/src/services/market-data/market-data.service.ts

@ -204,7 +204,7 @@ export class MarketDataService {
);
}
public async updateAssetProfileIdentifier(
public updateAssetProfileIdentifier(
oldAssetProfileIdentifier: AssetProfileIdentifier,
newAssetProfileIdentifier: AssetProfileIdentifier
) {

9
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -35,6 +35,15 @@ export class SymbolProfileService {
});
}
public deleteAssetProfileOverrides({
dataSource,
symbol
}: AssetProfileIdentifier) {
return this.prismaService.assetProfileOverrides.deleteMany({
where: { symbolProfile: { dataSource, symbol } }
});
}
public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({
where: { id }

17
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -408,7 +408,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
const dialogRef = this.dialog.open<
GfAssetProfileDialogComponent,
AssetProfileDialogParams
AssetProfileDialogParams,
AssetProfileIdentifier
>(GfAssetProfileDialogComponent, {
autoFocus: false,
data: {
@ -426,15 +427,13 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
dialogRef
.afterClosed()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(
(newAssetProfileIdentifier: AssetProfileIdentifier | undefined) => {
if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
.subscribe((newAssetProfileIdentifier) => {
if (newAssetProfileIdentifier) {
this.onOpenAssetProfileDialog(newAssetProfileIdentifier);
} else {
this.router.navigate(['.'], { relativeTo: this.route });
}
);
});
});
}

124
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -5,6 +5,7 @@ import {
PROPERTY_IS_DATA_GATHERING_ENABLED
} from '@ghostfolio/common/config';
import { UpdateAssetProfileDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
canDeleteAssetProfile,
DATE_FORMAT,
@ -75,6 +76,7 @@ import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource,
MarketData,
Prisma,
SymbolProfile
@ -215,6 +217,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
];
protected readonly DataSource = DataSource;
protected readonly dateRangeOptions = [
{
label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -277,7 +281,10 @@ export class GfAssetProfileDialogComponent implements OnInit {
@Inject(MAT_DIALOG_DATA) protected data: AssetProfileDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
private dialogRef: MatDialogRef<GfAssetProfileDialogComponent>,
private dialogRef: MatDialogRef<
GfAssetProfileDialogComponent,
AssetProfileIdentifier
>,
private formBuilder: FormBuilder,
private notificationService: NotificationService,
private snackBar: MatSnackBar,
@ -467,6 +474,19 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.dialogRef.close();
}
protected onConvertToManualDataSource() {
this.patchAssetProfileIdentifier({
getErrorMessage: () => {
return (
'😞 ' +
$localize`An error occurred while converting the data source to ${DataSource.MANUAL}.`
);
},
title: $localize`Do you really want to convert the data source to ${DataSource.MANUAL}?`,
updateAssetProfileDto: { dataSource: DataSource.MANUAL }
});
}
protected onDeleteProfileData({
dataSource,
symbol
@ -666,13 +686,16 @@ export class GfAssetProfileDialogComponent implements OnInit {
}
protected async onSubmitAssetProfileIdentifierForm() {
const newAssetProfileIdentifier =
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value;
if (!newAssetProfileIdentifier?.dataSource) {
return;
}
const assetProfileIdentifier: UpdateAssetProfileDto = {
dataSource:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.dataSource ?? undefined,
symbol:
this.assetProfileIdentifierForm.controls.assetProfileIdentifier.value
?.symbol ?? undefined
dataSource: newAssetProfileIdentifier.dataSource,
symbol: newAssetProfileIdentifier.symbol
};
try {
@ -687,46 +710,19 @@ export class GfAssetProfileDialogComponent implements OnInit {
return;
}
this.adminService
.patchAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
assetProfileIdentifier
)
.pipe(
catchError((error: HttpErrorResponse) => {
if (error.status === StatusCodes.CONFLICT) {
this.snackBar.open(
$localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`,
undefined,
{
duration: ms('3 seconds')
}
);
} else {
this.snackBar.open(
$localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`,
undefined,
{
duration: ms('3 seconds')
}
);
}
this.patchAssetProfileIdentifier({
getErrorMessage: (error) => {
if (error.status === StatusCodes.CONFLICT) {
// TODO: Ask if the user wants to merge the two asset profiles
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => {
const newAssetProfileIdentifier = {
dataSource: assetProfileIdentifier.dataSource,
symbol: assetProfileIdentifier.symbol
};
return $localize`${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}) is already in use.`;
}
this.dialogRef.close(newAssetProfileIdentifier);
});
return $localize`An error occurred while updating to ${assetProfileIdentifier.symbol} (${assetProfileIdentifier.dataSource}).`;
},
title: $localize`Do you really want to convert this asset profile to ${newAssetProfileIdentifier.symbol} (${newAssetProfileIdentifier.dataSource})?`,
updateAssetProfileDto: assetProfileIdentifier
});
}
protected onTestMarketData() {
@ -824,4 +820,42 @@ export class GfAssetProfileDialogComponent implements OnInit {
return null;
}
private patchAssetProfileIdentifier({
getErrorMessage,
title,
updateAssetProfileDto
}: {
getErrorMessage: (error: HttpErrorResponse) => string;
title: string;
updateAssetProfileDto: UpdateAssetProfileDto;
}) {
this.notificationService.confirm({
title,
confirmFn: () => {
this.adminService
.patchAssetProfile(
{
dataSource: this.data.dataSource,
symbol: this.data.symbol
},
updateAssetProfileDto
)
.pipe(
catchError((error: HttpErrorResponse) => {
this.snackBar.open(getErrorMessage(error), undefined, {
duration: ms('3 seconds')
});
return EMPTY;
}),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(({ dataSource, symbol }) => {
this.dialogRef.close({ dataSource, symbol });
});
},
confirmType: ConfirmationDialogType.Primary
});
}
}

13
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -162,6 +162,19 @@
<ng-container i18n>Cancel</ng-container>
</button>
</form>
@if (data.dataSource !== DataSource.MANUAL) {
<p class="my-1 px-1 text-muted" i18n>or</p>
<button
color="accent"
mat-flat-button
type="button"
(click)="onConvertToManualDataSource()"
>
<ng-container i18n
>Convert to {{ DataSource.MANUAL }}</ng-container
>
</button>
}
</div>
} @else {
<div class="col-6 mb-3">

Loading…
Cancel
Save