From d742e69a9b614d718674588b98d80dae19a0af9a Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 5 Dec 2023 14:03:17 +0100 Subject: [PATCH] fix: reports are own entities (#2106) closes #2104 MIGRATION REQUIRED Co-authored-by: Sebastian --- src/app/core/config/config-fix.ts | 130 --------------- .../config/demo-config-generator.service.ts | 3 +- src/app/core/demo-data/demo-data.module.ts | 2 + .../demo-report-config-generator.service.ts | 153 ++++++++++++++++++ src/app/features/reporting/report-config.ts | 30 ++++ .../reporting/reporting-component-config.ts | 28 ---- .../reporting/reporting.component.spec.ts | 20 +-- .../reporting/reporting.component.ts | 21 +-- .../select-report.component.spec.ts | 3 +- .../select-report/select-report.component.ts | 2 +- 10 files changed, 207 insertions(+), 185 deletions(-) create mode 100644 src/app/features/reporting/demo-report-config-generator.service.ts create mode 100644 src/app/features/reporting/report-config.ts delete mode 100644 src/app/features/reporting/reporting/reporting-component-config.ts diff --git a/src/app/core/config/config-fix.ts b/src/app/core/config/config-fix.ts index de0a443255..8a9c2b99e2 100644 --- a/src/app/core/config/config-fix.ts +++ b/src/app/core/config/config-fix.ts @@ -1,7 +1,6 @@ import { Child } from "../../child-dev-project/children/model/child"; import { School } from "../../child-dev-project/schools/model/school"; import { ChildSchoolRelation } from "../../child-dev-project/children/model/childSchoolRelation"; -import { EventNote } from "../../child-dev-project/attendance/model/event-note"; import { defaultDateFilters } from "../basic-datatypes/date/date-range-filter/date-range-filter-panel/date-range-filter-panel.component"; import { EducationalMaterial } from "../../child-dev-project/children/educational-material/model/educational-material"; @@ -784,135 +783,6 @@ export const defaultJsonConfig = { }, "view:report": { "component": "Reporting", - "config": { - "reports": [ - { - "title": $localize`:Name of a report:Basic Report`, - "aggregationDefinitions": [ - { - "query": `${Child.ENTITY_TYPE}:toArray[*isActive=true]`, - "label": $localize`:Label of report query:All children`, - "groupBy": ["gender"], - }, - { - "query": `${School.ENTITY_TYPE}:toArray`, - "label": $localize`:Label for report query:All schools`, - "aggregations": [ - { - "label": $localize`:Label for report query:Children attending a school`, - "query": `:getRelated(${ChildSchoolRelation.ENTITY_TYPE}, schoolId)[*isActive=true].childId:unique` - }, - { - "label": $localize`:Label for report query:Governmental schools`, - "query": `[*privateSchool!=true]` - }, - { - "query": `[*privateSchool!=true]:getRelated(${ChildSchoolRelation.ENTITY_TYPE}, schoolId)[*isActive=true].childId:addPrefix(${Child.ENTITY_TYPE}):unique:toEntities`, - "label": $localize`:Label for report query:Children attending a governmental school`, - "groupBy": ["gender"], - }, - { - "label": $localize`:Label for report query:Private schools`, - "query": `[*privateSchool=true]` - }, - { - "query": `[*privateSchool=true]:getRelated(${ChildSchoolRelation.ENTITY_TYPE}, schoolId)[*isActive=true].childId:addPrefix(${Child.ENTITY_TYPE}):unique:toEntities`, - "label": $localize`:Label for report query:Children attending a private school`, - "groupBy": ["gender"], - }, - ] - } - ], - }, - { - "title": $localize`:Name of a report:Event Report`, - "aggregationDefinitions": [ - { - "query": `${EventNote.ENTITY_TYPE}:toArray[*date >= ? & date <= ?]`, - "groupBy": ["category"], - "label": $localize`:Label for a report query:Events`, - "aggregations": [ - { - "query": `:getParticipantsWithAttendance(PRESENT):unique:addPrefix(${Child.ENTITY_TYPE}):toEntities`, - "groupBy": ["gender"], - "label": $localize`:Label for a report query:Participants` - } - ] - } - ], - }, - { - "title": $localize`:Name of a report:Attendance Report`, - "mode": "exporting", - "aggregationDefinitions": [ - { - "query": `${EventNote.ENTITY_TYPE}:toArray[* date >= ? & date <= ?]`, - "groupBy": { "label": "Type", "property": "category" }, - "subQueries": [ - { - "query": ":getAttendanceArray:getAttendanceReport", - "subQueries": [ - { - "label": $localize`:Name of a column of a report:Name`, - "query": `.participant:toEntities(Child).name` - }, - { - "query": ".participant:toEntities(Child):getRelated(ChildSchoolRelation, childId)[*isActive=true]", - "subQueries": [ - { - "label": "Class", - "query": ".schoolClass" - }, - { - "label": "School", - "query": ".schoolId:toEntities(School).name" - }, - ] - }, - { - "label": $localize`:Name of a column of a report:Total`, - "query": `total` - }, - { - "label": $localize`:Name of a column of a report:Present`, - "query": `present` - }, - { - "label": $localize`:Name of a column of a report:Rate`, - "query": `percentage` - }, - { - "label": $localize`:Name of a column of a report:Late`, - "query": `detailedStatus.LATE` - } - ] - } - ] - }, - ], - }, - { - "title": $localize`:Name of a report:Materials Distributed`, - "mode": "exporting", - "aggregationDefinitions": [ - { - "query": `${EducationalMaterial.ENTITY_TYPE}:toArray[*date >= ? & date <= ?]`, - "groupBy": { "label": "Type", "property": "materialType" }, - "subQueries": [ - { - "label": "Number of events of handing out", - "query": `.materialAmount:count` - }, - { - "label": "Total Items", - "query": `.materialAmount:sum` - }, - ] - }, - ] - } - ] - } }, "entity:Child": { diff --git a/src/app/core/config/demo-config-generator.service.ts b/src/app/core/config/demo-config-generator.service.ts index 0f3142f6eb..baea66e329 100644 --- a/src/app/core/config/demo-config-generator.service.ts +++ b/src/app/core/config/demo-config-generator.service.ts @@ -1,7 +1,6 @@ import { Injectable } from "@angular/core"; import { DemoDataGenerator } from "../demo-data/demo-data-generator"; import { Config } from "./config"; -import { DatabaseRules } from "../permissions/permission-types"; import { defaultJsonConfig } from "./config-fix"; @Injectable() @@ -15,7 +14,7 @@ export class DemoConfigGeneratorService extends DemoDataGenerator { ]; } - protected generateEntities(): Config[] { + protected generateEntities(): Config[] { const defaultConfig = JSON.parse(JSON.stringify(defaultJsonConfig)); return [new Config(Config.CONFIG_KEY, defaultConfig)]; } diff --git a/src/app/core/demo-data/demo-data.module.ts b/src/app/core/demo-data/demo-data.module.ts index f45c7da74b..0fa13022ab 100644 --- a/src/app/core/demo-data/demo-data.module.ts +++ b/src/app/core/demo-data/demo-data.module.ts @@ -39,6 +39,7 @@ import { DemoTodoGeneratorService } from "../../features/todos/model/demo-todo-g import { DemoConfigurableEnumGeneratorService } from "../basic-datatypes/configurable-enum/demo-configurable-enum-generator.service"; import { DemoPublicFormGeneratorService } from "../../features/public-form/demo-public-form-generator.service"; import { DemoSiteSettingsGeneratorService } from "../site-settings/demo-site-settings-generator.service"; +import { DemoReportConfigGeneratorService } from "../../features/reporting/demo-report-config-generator.service"; const demoDataGeneratorProviders = [ ...DemoConfigGeneratorService.provider(), @@ -69,6 +70,7 @@ const demoDataGeneratorProviders = [ maxCountAttributes: 5, }), ...DemoTodoGeneratorService.provider(), + ...DemoReportConfigGeneratorService.provider(), ]; /** diff --git a/src/app/features/reporting/demo-report-config-generator.service.ts b/src/app/features/reporting/demo-report-config-generator.service.ts new file mode 100644 index 0000000000..e5879b5f8f --- /dev/null +++ b/src/app/features/reporting/demo-report-config-generator.service.ts @@ -0,0 +1,153 @@ +import { Injectable } from "@angular/core"; +import { DemoDataGenerator } from "../../core/demo-data/demo-data-generator"; +import { ReportConfig } from "./report-config"; +import { Child } from "../../child-dev-project/children/model/child"; +import { School } from "../../child-dev-project/schools/model/school"; +import { ChildSchoolRelation } from "../../child-dev-project/children/model/childSchoolRelation"; +import { EventNote } from "../../child-dev-project/attendance/model/event-note"; +import { EducationalMaterial } from "../../child-dev-project/children/educational-material/model/educational-material"; + +@Injectable() +export class DemoReportConfigGeneratorService extends DemoDataGenerator { + static provider() { + return [ + { + provide: DemoReportConfigGeneratorService, + useClass: DemoReportConfigGeneratorService, + }, + ]; + } + + protected generateEntities(): ReportConfig[] { + return demoReports.map((report) => ReportConfig.create(report)); + } +} + +const demoReports: Partial[] = [ + { + title: $localize`:Name of a report:Basic Report`, + aggregationDefinitions: [ + { + query: `${Child.ENTITY_TYPE}:toArray[*isActive=true]`, + label: $localize`:Label of report query:All children`, + groupBy: ["gender"], + }, + { + query: `${School.ENTITY_TYPE}:toArray`, + label: $localize`:Label for report query:All schools`, + aggregations: [ + { + label: $localize`:Label for report query:Children attending a school`, + query: `:getRelated(${ChildSchoolRelation.ENTITY_TYPE}, schoolId)[*isActive=true].childId:unique`, + }, + { + label: $localize`:Label for report query:Governmental schools`, + query: `[*privateSchool!=true]`, + }, + { + query: `[*privateSchool!=true]:getRelated(${ChildSchoolRelation.ENTITY_TYPE}, schoolId)[*isActive=true].childId:addPrefix(${Child.ENTITY_TYPE}):unique:toEntities`, + label: $localize`:Label for report query:Children attending a governmental school`, + groupBy: ["gender"], + }, + { + label: $localize`:Label for report query:Private schools`, + query: `[*privateSchool=true]`, + }, + { + query: `[*privateSchool=true]:getRelated(${ChildSchoolRelation.ENTITY_TYPE}, schoolId)[*isActive=true].childId:addPrefix(${Child.ENTITY_TYPE}):unique:toEntities`, + label: $localize`:Label for report query:Children attending a private school`, + groupBy: ["gender"], + }, + ], + }, + ], + }, + { + title: $localize`:Name of a report:Event Report`, + aggregationDefinitions: [ + { + query: `${EventNote.ENTITY_TYPE}:toArray[*date >= ? & date <= ?]`, + groupBy: ["category"], + label: $localize`:Label for a report query:Events`, + aggregations: [ + { + query: `:getParticipantsWithAttendance(PRESENT):unique:addPrefix(${Child.ENTITY_TYPE}):toEntities`, + groupBy: ["gender"], + label: $localize`:Label for a report query:Participants`, + }, + ], + }, + ], + }, + { + title: $localize`:Name of a report:Attendance Report`, + mode: "exporting", + aggregationDefinitions: [ + { + query: `${EventNote.ENTITY_TYPE}:toArray[* date >= ? & date <= ?]`, + groupBy: { label: "Type", property: "category" }, + subQueries: [ + { + query: ":getAttendanceArray:getAttendanceReport", + subQueries: [ + { + label: $localize`:Name of a column of a report:Name`, + query: `.participant:toEntities(Child).name`, + }, + { + query: + ".participant:toEntities(Child):getRelated(ChildSchoolRelation, childId)[*isActive=true]", + subQueries: [ + { + label: "Class", + query: ".schoolClass", + }, + { + label: "School", + query: ".schoolId:toEntities(School).name", + }, + ], + }, + { + label: $localize`:Name of a column of a report:Total`, + query: `total`, + }, + { + label: $localize`:Name of a column of a report:Present`, + query: `present`, + }, + { + label: $localize`:Name of a column of a report:Rate`, + query: `percentage`, + }, + { + label: $localize`:Name of a column of a report:Late`, + query: `detailedStatus.LATE`, + }, + ], + }, + ], + }, + ], + }, + { + title: $localize`:Name of a report:Materials Distributed`, + mode: "exporting", + aggregationDefinitions: [ + { + query: `${EducationalMaterial.ENTITY_TYPE}:toArray[*date >= ? & date <= ?]`, + groupBy: { label: "Type", property: "materialType" }, + subQueries: [ + { + label: "Number of events of handing out", + query: `.materialAmount:count`, + }, + { + label: "Total Items", + query: `.materialAmount:sum`, + }, + ], + }, + ], + }, +]; diff --git a/src/app/features/reporting/report-config.ts b/src/app/features/reporting/report-config.ts new file mode 100644 index 0000000000..a3ad9b3411 --- /dev/null +++ b/src/app/features/reporting/report-config.ts @@ -0,0 +1,30 @@ +import { Entity } from "../../core/entity/model/entity"; +import { DatabaseEntity } from "../../core/entity/database-entity.decorator"; +import { Aggregation } from "./data-aggregation.service"; +import { ExportColumnConfig } from "../../core/export/data-transformation-service/export-column-config"; +import { DatabaseField } from "../../core/entity/database-field.decorator"; + +/** + * A report can be accessed by users to generate aggregated statistics or customized exports calculated from available data. + * "read" permission for a ReportConfig entity is also used to control which users can generate the report's results. + */ +@DatabaseEntity("ReportConfig") +export class ReportConfig extends Entity { + static create(data: Partial) { + return Object.assign(new ReportConfig(), data); + } + + /** human-readable title of the report */ + @DatabaseField() title: string; + + /** + * (optional) mode whether the aggregation definitions are of type {@interface Aggregation} or {@interface ExportColumnConfig} + * Default is "reporting" + */ + @DatabaseField() mode?: "reporting" | "exporting"; + + /** the definitions to calculate the report's aggregations */ + @DatabaseField() aggregationDefinitions?: + | Aggregation[] + | ExportColumnConfig[] = []; +} diff --git a/src/app/features/reporting/reporting/reporting-component-config.ts b/src/app/features/reporting/reporting/reporting-component-config.ts deleted file mode 100644 index 42ab118789..0000000000 --- a/src/app/features/reporting/reporting/reporting-component-config.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Aggregation } from "../data-aggregation.service"; -import { ExportColumnConfig } from "../../../core/export/data-transformation-service/export-column-config"; - -/** - * The config object format that can be set for this component in the config database - * specifying all available reports. - */ -export interface ReportingComponentConfig { - /** array of available reports */ - reports: ReportConfig[]; -} - -/** - * The config for one specific report that can be selected and calculated. - */ -export interface ReportConfig { - /** human-readable title of the report */ - title: string; - - /** - * (optional) mode whether the aggregation definitions are of type {@interface Aggregation} or {@interface ExportColumnConfig} - * Default is "reporting" - */ - mode?: "reporting" | "exporting"; - - /** the definitions to calculate the report's aggregations */ - aggregationDefinitions?: Aggregation[] | ExportColumnConfig[]; -} diff --git a/src/app/features/reporting/reporting/reporting.component.spec.ts b/src/app/features/reporting/reporting/reporting.component.spec.ts index c64b4b44f4..6285a20ca9 100644 --- a/src/app/features/reporting/reporting/reporting.component.spec.ts +++ b/src/app/features/reporting/reporting/reporting.component.spec.ts @@ -7,8 +7,6 @@ import { import { ReportingComponent } from "./reporting.component"; import { NoopAnimationsModule } from "@angular/platform-browser/animations"; -import { Subject } from "rxjs"; -import { ActivatedRoute } from "@angular/router"; import { Aggregation, DataAggregationService, @@ -16,23 +14,20 @@ import { import { MatNativeDateModule } from "@angular/material/core"; import { defaultInteractionTypes } from "../../../core/config/default-config/default-interaction-types"; import { ReportRow } from "../report-row"; -import { - ReportConfig, - ReportingComponentConfig, -} from "./reporting-component-config"; -import { RouteData } from "../../../core/config/dynamic-routing/view-config.interface"; import { RouterTestingModule } from "@angular/router/testing"; import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testing"; import { DataTransformationService } from "../../../core/export/data-transformation-service/data-transformation.service"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { mockEntityMapper } from "../../../core/entity/entity-mapper/mock-entity-mapper-service"; +import { ReportConfig } from "../report-config"; describe("ReportingComponent", () => { let component: ReportingComponent; let fixture: ComponentFixture; - const mockRouteData = new Subject>(); let mockReportingService: jasmine.SpyObj; let mockDataTransformationService: jasmine.SpyObj; - const testReport: ReportConfig = { + const testReport: ReportConfig = ReportConfig.create({ title: "test report", aggregationDefinitions: [ { @@ -42,7 +37,7 @@ describe("ReportingComponent", () => { aggregations: [], }, ], - }; + }); beforeEach(async () => { mockReportingService = jasmine.createSpyObj(["calculateReport"]); @@ -59,12 +54,12 @@ describe("ReportingComponent", () => { RouterTestingModule, ], providers: [ - { provide: ActivatedRoute, useValue: { data: mockRouteData } }, { provide: DataAggregationService, useValue: mockReportingService }, { provide: DataTransformationService, useValue: mockDataTransformationService, }, + { provide: EntityMapperService, useValue: mockEntityMapper() }, ], }).compileComponents(); }); @@ -73,7 +68,6 @@ describe("ReportingComponent", () => { fixture = TestBed.createComponent(ReportingComponent); component = fixture.componentInstance; fixture.detectChanges(); - mockRouteData.next({ config: { reports: [] } }); }); it("should create", () => { @@ -208,7 +202,7 @@ describe("ReportingComponent", () => { mockDataTransformationService.queryAndTransformData.and.resolveTo(data); await component.calculateResults( - { aggregationDefinitions: [], title: "", mode: "exporting" }, + ReportConfig.create({ mode: "exporting" }), new Date(), new Date(), ); diff --git a/src/app/features/reporting/reporting/reporting.component.ts b/src/app/features/reporting/reporting/reporting.component.ts index 3e35bb826f..eee935032a 100644 --- a/src/app/features/reporting/reporting/reporting.component.ts +++ b/src/app/features/reporting/reporting/reporting.component.ts @@ -1,5 +1,4 @@ -import { Component, Input } from "@angular/core"; -import { ActivatedRoute } from "@angular/router"; +import { Component } from "@angular/core"; import { Aggregation, DataAggregationService, @@ -8,10 +7,6 @@ import { getGroupingInformationString, GroupByDescription, } from "../report-row"; -import { - ReportConfig, - ReportingComponentConfig, -} from "./reporting-component-config"; import moment from "moment"; import { ExportColumnConfig } from "../../../core/export/data-transformation-service/export-column-config"; import { RouteTarget } from "../../../app.routing"; @@ -21,6 +16,8 @@ import { SelectReportComponent } from "./select-report/select-report.component"; import { ReportRowComponent } from "./report-row/report-row.component"; import { ObjectTableComponent } from "./object-table/object-table.component"; import { DataTransformationService } from "../../../core/export/data-transformation-service/data-transformation.service"; +import { EntityMapperService } from "../../../core/entity/entity-mapper/entity-mapper.service"; +import { ReportConfig } from "../report-config"; @RouteTarget("Reporting") @Component({ @@ -36,8 +33,8 @@ import { DataTransformationService } from "../../../core/export/data-transformat ], standalone: true, }) -export class ReportingComponent implements ReportingComponentConfig { - @Input() reports: ReportConfig[]; +export class ReportingComponent { + reports: ReportConfig[]; mode: "exporting" | "reporting" = "exporting"; loading: boolean; @@ -45,10 +42,14 @@ export class ReportingComponent implements ReportingComponentConfig { exportableData: any[]; constructor( - private activatedRoute: ActivatedRoute, private dataAggregationService: DataAggregationService, private dataTransformationService: DataTransformationService, - ) {} + private entityMapper: EntityMapperService, + ) { + this.entityMapper + .loadType(ReportConfig) + .then((res) => (this.reports = res)); + } async calculateResults( selectedReport: ReportConfig, diff --git a/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts b/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts index dd86d1379e..f46ad8d547 100644 --- a/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts +++ b/src/app/features/reporting/reporting/select-report/select-report.component.spec.ts @@ -1,6 +1,7 @@ import { ComponentFixture, TestBed } from "@angular/core/testing"; import { SelectReportComponent } from "./select-report.component"; +import { ReportConfig } from "../../report-config"; describe("SelectReportComponent", () => { let component: SelectReportComponent; @@ -23,7 +24,7 @@ describe("SelectReportComponent", () => { }); it("should select the first report if only one exists", () => { - const report = { title: "someReport", aggregationDefinitions: [] }; + const report = ReportConfig.create({ title: "someReport" }); component.reports = [report]; component.ngOnChanges({ reports: undefined }); diff --git a/src/app/features/reporting/reporting/select-report/select-report.component.ts b/src/app/features/reporting/reporting/select-report/select-report.component.ts index 5a43613b27..160deb187f 100644 --- a/src/app/features/reporting/reporting/select-report/select-report.component.ts +++ b/src/app/features/reporting/reporting/select-report/select-report.component.ts @@ -6,7 +6,6 @@ import { Output, SimpleChanges, } from "@angular/core"; -import { ReportConfig } from "../reporting-component-config"; import { NgForOf, NgIf } from "@angular/common"; import { MatButtonModule } from "@angular/material/button"; import { MatFormFieldModule } from "@angular/material/form-field"; @@ -18,6 +17,7 @@ import { ExportDataDirective } from "../../../../core/export/export-data-directi import { FontAwesomeModule } from "@fortawesome/angular-fontawesome"; import { MatProgressBarModule } from "@angular/material/progress-bar"; import { MatTooltipModule } from "@angular/material/tooltip"; +import { ReportConfig } from "../../report-config"; @Component({ selector: "app-select-report",