Browse Source

Merge branch 'main' into import_deduplication_fix

pull/2704/head
Thomas Kaul 2 years ago
committed by GitHub
parent
commit
95c22e0ab1
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 8
      CHANGELOG.md
  2. 6
      apps/api/src/decorators/has-permission.decorator.ts
  3. 55
      apps/api/src/guards/has-permission.guard.spec.ts
  4. 37
      apps/api/src/guards/has-permission.guard.ts
  5. 2
      apps/api/src/services/data-provider/data-provider.service.ts
  6. 12
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  7. 25
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html
  8. 4
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts
  9. 2
      apps/client/src/app/pages/portfolio/activities/activities-page.html
  10. 7
      apps/client/src/app/services/data.service.ts
  11. 13
      apps/client/src/assets/oss-friends.json
  12. 36
      libs/ui/src/lib/account-balances/account-balances.component.html
  13. 5
      libs/ui/src/lib/account-balances/account-balances.component.scss
  14. 63
      libs/ui/src/lib/account-balances/account-balances.component.ts
  15. 15
      libs/ui/src/lib/account-balances/account-balances.module.ts

8
CHANGELOG.md

@ -9,11 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Respected the `withExcludedAccounts` flag in the account balance time series
- Added a historical cash balances table to the account detail dialog
- Introduced a `HasPermission` annotation for endpoints
### Changed
- Relaxed the check for duplicates in the preview step of the activities import (allow same day)
- Respected the `withExcludedAccounts` flag in the account balance time series
### Fixed
- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks
## 2.27.1 - 2023-11-28

6
apps/api/src/decorators/has-permission.decorator.ts

@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const HAS_PERMISSION_KEY = 'has_permission';
export function HasPermission(permission: string) {
return SetMetadata(HAS_PERMISSION_KEY, permission);
}

55
apps/api/src/guards/has-permission.guard.spec.ts

@ -0,0 +1,55 @@
import { HttpException } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host';
import { Test, TestingModule } from '@nestjs/testing';
import { HasPermissionGuard } from './has-permission.guard';
describe('HasPermissionGuard', () => {
let guard: HasPermissionGuard;
let reflector: Reflector;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HasPermissionGuard, Reflector]
}).compile();
guard = module.get<HasPermissionGuard>(HasPermissionGuard);
reflector = module.get<Reflector>(Reflector);
});
function setupReflectorSpy(returnValue: string) {
jest.spyOn(reflector, 'get').mockReturnValue(returnValue);
}
function createMockExecutionContext(permissions: string[]) {
return new ExecutionContextHost([
{
user: {
permissions // Set user permissions based on the argument
}
}
]);
}
it('should deny access if the user does not have any permission', () => {
setupReflectorSpy('required-permission');
const noPermissions = createMockExecutionContext([]);
expect(() => guard.canActivate(noPermissions)).toThrow(HttpException);
});
it('should deny access if the user has the wrong permission', () => {
setupReflectorSpy('required-permission');
const wrongPermission = createMockExecutionContext(['wrong-permission']);
expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException);
});
it('should allow access if the user has the required permission', () => {
setupReflectorSpy('required-permission');
const rightPermission = createMockExecutionContext(['required-permission']);
expect(guard.canActivate(rightPermission)).toBe(true);
});
});

37
apps/api/src/guards/has-permission.guard.ts

@ -0,0 +1,37 @@
import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator';
import { hasPermission } from '@ghostfolio/common/permissions';
import {
CanActivate,
ExecutionContext,
HttpException,
Injectable
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class HasPermissionGuard implements CanActivate {
public constructor(private reflector: Reflector) {}
public canActivate(context: ExecutionContext): boolean {
const requiredPermission = this.reflector.get<string>(
HAS_PERMISSION_KEY,
context.getHandler()
);
if (!requiredPermission) {
return true; // No specific permissions required
}
const { user } = context.switchToHttp().getRequest();
if (!user || !hasPermission(user.permissions, requiredPermission)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return true;
}
}

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

@ -346,7 +346,7 @@ export class DataProviderService {
);
try {
this.marketDataService.updateMany({
await this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
return (

12
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -29,14 +29,15 @@ import { AccountDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./account-detail-dialog.component.scss']
})
export class AccountDetailDialog implements OnDestroy, OnInit {
public activities: OrderWithAccount[];
public balance: number;
public currency: string;
public equity: number;
public hasImpersonationId: boolean;
public historicalDataItems: HistoricalDataItem[];
public isLoadingActivities: boolean;
public isLoadingChart: boolean;
public name: string;
public orders: OrderWithAccount[];
public platformName: string;
public transactionCount: number;
public user: User;
@ -64,6 +65,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
public ngOnInit() {
this.isLoadingActivities = true;
this.isLoadingChart = true;
this.dataService
@ -103,7 +105,9 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ activities }) => {
this.orders = activities;
this.activities = activities;
this.isLoadingActivities = false;
this.changeDetectorRef.markForCheck();
});
@ -153,8 +157,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public onExport() {
this.dataService
.fetchExport(
this.orders.map((order) => {
return order.id;
this.activities.map(({ id }) => {
return id;
})
)
.pipe(takeUntil(this.unsubscribeSubject))

25
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.html

@ -31,7 +31,7 @@
></gf-investment-chart>
</div>
<div class="row">
<div class="mb-3 row">
<div class="col-6 mb-3">
<gf-value
i18n
@ -64,11 +64,15 @@
</div>
</div>
<div class="row" [ngClass]="{ 'd-none': !orders?.length }">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<mat-tab-group
animationDuration="0"
[mat-stretch-tabs]="false"
[ngClass]="{ 'd-none': isLoadingActivities }"
>
<mat-tab>
<ng-template i18n mat-tab-label>Activities</ng-template>
<gf-activities-table
[activities]="orders"
[activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
@ -79,8 +83,15 @@
[showActions]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
</mat-tab>
<mat-tab>
<ng-template i18n mat-tab-label>Cash Balances</ng-template>
<gf-account-balances
[accountId]="data.accountId"
[locale]="user?.settings?.locale"
></gf-account-balances>
</mat-tab>
</mat-tab-group>
</div>
</div>

4
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.module.ts

@ -2,9 +2,11 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTabsModule } from '@angular/material/tabs';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { GfAccountBalancesModule } from '@ghostfolio/ui/account-balances/account-balances.module';
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -15,6 +17,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
declarations: [AccountDetailDialog],
imports: [
CommonModule,
GfAccountBalancesModule,
GfActivitiesTableModule,
GfDialogFooterModule,
GfDialogHeaderModule,
@ -22,6 +25,7 @@ import { AccountDetailDialog } from './account-detail-dialog.component';
GfValueModule,
MatButtonModule,
MatDialogModule,
MatTabsModule,
NgxSkeletonLoaderModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

2
apps/client/src/app/pages/portfolio/activities/activities-page.html

@ -1,5 +1,5 @@
<div class="container">
<div class="row mb-3">
<div class="mb-3 row">
<div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Activities</h1>
<gf-activities-table

7
apps/client/src/app/services/data.service.ts

@ -18,6 +18,7 @@ import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
Access,
AccountBalancesResponse,
Accounts,
BenchmarkMarketDataDetails,
BenchmarkResponse,
@ -137,6 +138,12 @@ export class DataService {
return this.http.get<AccountWithValue>(`/api/v1/account/${aAccountId}`);
}
public fetchAccountBalances(aAccountId: string) {
return this.http.get<AccountBalancesResponse>(
`/api/v1/account/${aAccountId}/balances`
);
}
public fetchAccounts() {
return this.http.get<Accounts>('/api/v1/account');
}

13
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{
"createdAt": "2023-11-17T00:00:00.000Z",
"createdAt": "2023-11-30T00:00:00.000Z",
"data": [
{
"name": "BoxyHQ",
@ -16,6 +16,11 @@
"description": "Centralize community, product, and customer data to understand which companies are engaging with your open source project.",
"href": "https://www.crowd.dev"
},
{
"name": "DevHunt",
"description": "Find the best Dev Tools upvoted by the community every week.",
"href": "https://devhunt.org"
},
{
"name": "Documenso",
"description": "The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
@ -59,7 +64,7 @@
{
"name": "Hook0",
"description": "Open-Source Webhooks-as-a-service (WaaS) that makes it easy for developers to send webhooks.",
"href": "https://www.hook0.com/"
"href": "https://www.hook0.com"
},
{
"name": "HTMX",
@ -89,7 +94,7 @@
{
"name": "Papermark",
"description": "Open-Source Docsend Alternative to securely share documents with real-time analytics.",
"href": "https://www.papermark.io/"
"href": "https://www.papermark.io"
},
{
"name": "Requestly",
@ -109,7 +114,7 @@
{
"name": "Shelf.nu",
"description": "Open Source Asset and Equipment tracking software that lets you create QR asset labels, manage and overview your assets across locations.",
"href": "https://www.shelf.nu/"
"href": "https://www.shelf.nu"
},
{
"name": "Sniffnet",

36
libs/ui/src/lib/account-balances/account-balances.component.html

@ -0,0 +1,36 @@
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="date"
matSortDirection="desc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header>
<ng-container i18n>Date</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
<gf-value [isDate]="true" [locale]="locale" [value]="element?.date" />
</td>
</ng-container>
<ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-2 text-right" mat-header-cell>
<ng-container i18n>Value</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[unit]="element?.Account?.currency"
[value]="element?.value"
></gf-value>
</div>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

5
libs/ui/src/lib/account-balances/account-balances.component.scss

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

63
libs/ui/src/lib/account-balances/account-balances.component.ts

@ -0,0 +1,63 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { DataService } from '@ghostfolio/client/services/data.service';
import { AccountBalancesResponse } from '@ghostfolio/common/interfaces';
import { get } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-account-balances',
styleUrls: ['./account-balances.component.scss'],
templateUrl: './account-balances.component.html'
})
export class AccountBalancesComponent implements OnDestroy, OnInit {
@Input() accountId: string;
@Input() locale: string;
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<
AccountBalancesResponse['balances'][0]
> = new MatTableDataSource();
public displayedColumns: string[] = ['date', 'value'];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService
) {}
public ngOnInit() {
this.fetchBalances();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchBalances() {
this.dataService
.fetchAccountBalances(this.accountId)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ balances }) => {
this.dataSource = new MatTableDataSource(balances);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
this.changeDetectorRef.markForCheck();
});
}
}

15
libs/ui/src/lib/account-balances/account-balances.module.ts

@ -0,0 +1,15 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AccountBalancesComponent } from './account-balances.component';
@NgModule({
declarations: [AccountBalancesComponent],
exports: [AccountBalancesComponent],
imports: [CommonModule, GfValueModule, MatSortModule, MatTableModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAccountBalancesModule {}
Loading…
Cancel
Save