Skip to content

Commit

Permalink
feat: supporting SQL reports (#2123)
Browse files Browse the repository at this point in the history
Co-authored-by: Sebastian <sebastian@aam-digital.com>
  • Loading branch information
TheSlimvReal and sleidig authored Dec 16, 2023
1 parent 3b4168b commit f901116
Show file tree
Hide file tree
Showing 14 changed files with 430 additions and 94 deletions.
4 changes: 3 additions & 1 deletion build/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ COPY --from=builder /app/dist/ /usr/share/nginx/html
ENV PORT=80
# The url to the CouchDB database
ENV COUCHDB_URL="http://localhost"
# The url to the query backend, see https://github.com/Aam-Digital/query-backend
ENV QUERY_URL="http://localhost:3000"
# The url to a nominatim instance, see https://nominatim.org/
ENV NOMINATIM_URL="https://nominatim.openstreetmap.org"

Expand All @@ -91,5 +93,5 @@ ENV CSP="default-src 'self' 'unsafe-eval' data: https://*.tile.openstreetmap.org
### 'unsafe-eval' required for pouchdb https://github.com/pouchdb/pouchdb/issues/7853#issuecomment-535020600

# variables are inserted into the nginx config
CMD envsubst '$$PORT $$COUCHDB_URL $$NOMINATIM_URL $$CSP $$CSP_REPORT_URI' < /etc/nginx/templates/default.conf > /etc/nginx/conf.d/default.conf &&\
CMD envsubst '$$PORT $$COUCHDB_URL $$QUERY_URL $$NOMINATIM_URL $$CSP $$CSP_REPORT_URI' < /etc/nginx/templates/default.conf > /etc/nginx/conf.d/default.conf &&\
nginx -g 'daemon off;'
12 changes: 12 additions & 0 deletions build/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,18 @@ server {
proxy_set_header X-Forwarded-Ssl on;
}

# The proxy path should be the same as in SqlReportService.QUERY_PROXY
location ^~ /query {
rewrite /query/(.*) /$1 break;
proxy_pass ${QUERY_URL};
proxy_redirect off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Ssl on;
}

# The proxy path should be the same as in GeoService.remoteUrl
location ^~ /nominatim {
rewrite /nominatim/(.*) /$1 break;
proxy_pass ${NOMINATIM_URL};
Expand Down
9 changes: 9 additions & 0 deletions proxy.conf.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@
"/db": ""
}
},
"/query": {
"target": "http://localhost:3000",
"secure": true,
"logLevel": "debug",
"changeOrigin": true,
"pathRewrite": {
"/query": ""
}
},
"/nominatim": {
"target": "https://nominatim.openstreetmap.org",
"secure": true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ describe("EntityListComponent", () => {

it("should set the clicked column group", async () => {
createComponent();
// Test only works in desktop mode
component.isDesktop = true;
await initComponentInputs();
expect(component.selectedColumnGroupIndex).toBe(1);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { Injectable } from "@angular/core";
import { DemoDataGenerator } from "../../core/demo-data/demo-data-generator";
import { ReportConfig } from "./report-config";
import { ReportEntity } 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<ReportConfig> {
export class DemoReportConfigGeneratorService extends DemoDataGenerator<ReportEntity> {
static provider() {
return [
{
Expand All @@ -18,12 +18,14 @@ export class DemoReportConfigGeneratorService extends DemoDataGenerator<ReportCo
];
}

protected generateEntities(): ReportConfig[] {
return demoReports.map((report) => ReportConfig.create(report));
protected generateEntities(): ReportEntity[] {
return demoReports.map((report) =>
Object.assign(new ReportEntity(), report),
);
}
}

const demoReports: Partial<ReportConfig>[] = [
const demoReports: Partial<ReportEntity>[] = [
{
title: $localize`:Name of a report:Basic Report`,
aggregationDefinitions: [
Expand Down
60 changes: 49 additions & 11 deletions src/app/features/reporting/report-config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Entity } from "../../core/entity/model/entity";
import { Entity, EntityConstructor } 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";
Expand All @@ -7,24 +7,62 @@ 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.
*
* This is the class which is saved to the database.
* However, when using this in code, use the {@link ReportEntity} instead which provides better type safety.
*/
@DatabaseEntity("ReportConfig")
export class ReportConfig extends Entity {
static create(data: Partial<ReportConfig>) {
return Object.assign(new ReportConfig(), data);
}

class ReportConfig extends Entity {
/** human-readable title of the report */
@DatabaseField() title: string;

/**
* (optional) mode whether the aggregation definitions are of type {@interface Aggregation} or {@interface ExportColumnConfig}
* (optional) mode of export.
* The {@link ReportEntity} holds the restriction on valid report modes.
* Default is "reporting"
*/
@DatabaseField() mode?: "reporting" | "exporting";
@DatabaseField() mode?: string;

/** the definitions to calculate the report's aggregations */
@DatabaseField() aggregationDefinitions?:
| Aggregation[]
| ExportColumnConfig[] = [];
@DatabaseField() aggregationDefinitions: any[] = [];
}

/**
* Union type to enable type safety for report configs.
* Use this instead of the {@class ReportConfig}
*/
export type ReportEntity = AggregationReport | ExportingReport | SqlReport;
/**
* This allows the `ReportEntity` to also be used as a constructor or in the `EntityMapper`
*/
export const ReportEntity = ReportConfig as EntityConstructor<ReportEntity>;

/**
* Reports handles by the {@class DataAggregationService}
*/
export interface AggregationReport extends ReportConfig {
mode: "reporting";
aggregationDefinitions: Aggregation[];
}

/**
* Reports handles by the {@class DataTransformationService}
*/
export interface ExportingReport extends ReportConfig {
/**
* If no mode is set, it will default to 'exporting'
*/
mode?: "exporting";
aggregationDefinitions: ExportColumnConfig[];
}

/**
* Reports handles by the {@class SqlReportService}
*/
export interface SqlReport extends ReportConfig {
mode: "sql";
/**
* Array of valid SQL SELECT statements
*/
aggregationDefinitions: string[];
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,6 @@
[rows]="data"
></app-report-row>
<app-object-table
*ngIf="data?.length > 0 && mode === 'exporting'"
*ngIf="data?.length > 0 && (mode === 'exporting' || mode === 'sql')"
[objects]="data"
></app-object-table>
46 changes: 28 additions & 18 deletions src/app/features/reporting/reporting/reporting.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,32 +19,25 @@ import { FontAwesomeTestingModule } from "@fortawesome/angular-fontawesome/testi
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";
import { ReportEntity, SqlReport } from "../report-config";
import { SqlReportService } from "../sql-report/sql-report.service";

describe("ReportingComponent", () => {
let component: ReportingComponent;
let fixture: ComponentFixture<ReportingComponent>;
let mockReportingService: jasmine.SpyObj<DataAggregationService>;
let mockDataTransformationService: jasmine.SpyObj<DataTransformationService>;
let mockSqlReportService: jasmine.SpyObj<SqlReportService>;

const testReport: ReportConfig = ReportConfig.create({
title: "test report",
aggregationDefinitions: [
{
query: "some query",
label: "some label",
groupBy: ["some", "values"],
aggregations: [],
},
],
});
const testReport = new ReportEntity();

beforeEach(async () => {
mockReportingService = jasmine.createSpyObj(["calculateReport"]);
mockDataTransformationService = jasmine.createSpyObj([
"queryAndTransformData",
]);
mockReportingService.calculateReport.and.resolveTo([]);
mockSqlReportService = jasmine.createSpyObj(["query"]);
await TestBed.configureTestingModule({
imports: [
ReportingComponent,
Expand All @@ -59,6 +52,7 @@ describe("ReportingComponent", () => {
provide: DataTransformationService,
useValue: mockDataTransformationService,
},
{ provide: SqlReportService, useValue: mockSqlReportService },
{ provide: EntityMapperService, useValue: mockEntityMapper() },
],
}).compileComponents();
Expand Down Expand Up @@ -194,23 +188,39 @@ describe("ReportingComponent", () => {
]);
}));

it("should use the export service when aggregation has mode 'exporting'", async () => {
it("should use the export service when report has mode 'exporting'", async () => {
const data = [
{ First: 1, Second: 2 },
{ First: 3, Second: 4 },
];
mockDataTransformationService.queryAndTransformData.and.resolveTo(data);
const report = new ReportEntity();
report.mode = "exporting";

await component.calculateResults(
ReportConfig.create({ mode: "exporting" }),
new Date(),
new Date(),
);
await component.calculateResults(report, new Date(), new Date());

expect(
mockDataTransformationService.queryAndTransformData,
).toHaveBeenCalledWith([], jasmine.any(Date), jasmine.any(Date));
expect(component.data).toEqual(data);
expect(component.mode).toBe("exporting");
});

it("should use the sql report service when report has mode 'sql'", async () => {
const report = new ReportEntity() as SqlReport;
report.mode = "sql";

await component.calculateResults(
report,
new Date("2023-01-01"),
new Date("2023-12-31"),
);

expect(mockSqlReportService.query).toHaveBeenCalledWith(
report,
new Date("2023-01-01"),
// Next day (to date is exclusive
new Date("2024-01-01"),
);
});
});
Loading

0 comments on commit f901116

Please sign in to comment.