From 0c62c1c86417f5cde32c9d9c25129ce5cee3a21b Mon Sep 17 00:00:00 2001 From: Sean Engelhardt Date: Fri, 9 Nov 2018 13:44:39 +0100 Subject: [PATCH] History mode on client side Add view for full history and History Repom TimeTravelService Add function time travel routine Updated the HTTP Service, fixed usage of storage, OSStatus Service, fixed loading of the history data --- client/src/app/core/query-params.ts | 30 ++++ .../src/app/core/services/app-load.service.ts | 4 +- .../app/core/services/data-store.service.ts | 19 ++- client/src/app/core/services/http.service.ts | 86 ++++++++---- .../openslides-status.service.spec.ts | 17 +++ .../services/openslides-status.service.ts | 42 ++++++ .../src/app/core/services/storage.service.ts | 16 ++- .../core/services/time-travel.service.spec.ts | 16 +++ .../app/core/services/time-travel.service.ts | 119 ++++++++++++++++ .../app/core/services/websocket.service.ts | 32 +---- client/src/app/shared/models/core/history.ts | 39 ++++++ .../assignment-repository.service.spec.ts | 3 +- .../services/assignment-repository.service.ts | 7 +- client/src/app/site/base/base-repository.ts | 8 +- client/src/app/site/base/base-view.ts | 10 +- .../services/config-repository.service.ts | 5 + .../history-list/history-list.component.html | 58 ++++++++ .../history-list/history-list.component.scss | 26 ++++ .../history-list.component.spec.ts | 26 ++++ .../history-list/history-list.component.ts | 112 +++++++++++++++ .../site/history/history-routing.module.ts | 17 +++ client/src/app/site/history/history.config.ts | 20 +++ client/src/app/site/history/history.module.ts | 16 +++ .../app/site/history/models/view-history.ts | 132 ++++++++++++++++++ .../history-repository.service.spec.ts | 18 +++ .../services/history-repository.service.ts | 102 ++++++++++++++ .../services/mediafile-repository.service.ts | 2 +- .../services/category-repository.service.ts | 8 +- ...hange-recommendation-repository.service.ts | 6 +- .../services/local-permissions.service.ts | 6 +- .../services/motion-repository.service.ts | 12 +- .../statute-paragraph-repository.service.ts | 5 +- client/src/app/site/site-routing.module.ts | 4 + client/src/app/site/site.component.html | 4 + client/src/app/site/site.component.scss | 28 +++- client/src/app/site/site.component.ts | 11 +- .../tags/services/tag-repository.service.ts | 5 +- .../services/group-repository.service.ts | 6 +- .../users/services/user-repository.service.ts | 6 +- openslides/motions/signals.py | 4 +- 40 files changed, 986 insertions(+), 101 deletions(-) create mode 100644 client/src/app/core/query-params.ts create mode 100644 client/src/app/core/services/openslides-status.service.spec.ts create mode 100644 client/src/app/core/services/openslides-status.service.ts create mode 100644 client/src/app/core/services/time-travel.service.spec.ts create mode 100644 client/src/app/core/services/time-travel.service.ts create mode 100644 client/src/app/shared/models/core/history.ts create mode 100644 client/src/app/site/history/components/history-list/history-list.component.html create mode 100644 client/src/app/site/history/components/history-list/history-list.component.scss create mode 100644 client/src/app/site/history/components/history-list/history-list.component.spec.ts create mode 100644 client/src/app/site/history/components/history-list/history-list.component.ts create mode 100644 client/src/app/site/history/history-routing.module.ts create mode 100644 client/src/app/site/history/history.config.ts create mode 100644 client/src/app/site/history/history.module.ts create mode 100644 client/src/app/site/history/models/view-history.ts create mode 100644 client/src/app/site/history/services/history-repository.service.spec.ts create mode 100644 client/src/app/site/history/services/history-repository.service.ts diff --git a/client/src/app/core/query-params.ts b/client/src/app/core/query-params.ts new file mode 100644 index 000000000..54c50c0de --- /dev/null +++ b/client/src/app/core/query-params.ts @@ -0,0 +1,30 @@ + +type QueryParamValue = string | number | boolean; + +/** + * A key value mapping for params, that should be appended to the url on a new connection. + */ +export interface QueryParams { + [key: string]: QueryParamValue; +} + +/** + * Formats query params for the url. + * + * @param queryParams + * @returns the formatted query params as string + */ +export function formatQueryParams(queryParams: QueryParams = {}): string { + let params = ''; + const keys: string[] = Object.keys(queryParams); + if (keys.length > 0) { + params = + '?' + + keys + .map(key => { + return key + '=' + queryParams[key].toString(); + }) + .join('&'); + } + return params; +} diff --git a/client/src/app/core/services/app-load.service.ts b/client/src/app/core/services/app-load.service.ts index c229c66e5..662a6b1ba 100644 --- a/client/src/app/core/services/app-load.service.ts +++ b/client/src/app/core/services/app-load.service.ts @@ -11,6 +11,7 @@ import { AssignmentsAppConfig } from '../../site/assignments/assignments.config' import { UsersAppConfig } from '../../site/users/users.config'; import { TagAppConfig } from '../../site/tags/tag.config'; import { MainMenuService } from './main-menu.service'; +import { HistoryAppConfig } from 'app/site/history/history.config'; /** * A list of all app configurations of all delivered apps. @@ -23,7 +24,8 @@ const appConfigs: AppConfig[] = [ MotionsAppConfig, MediafileAppConfig, TagAppConfig, - UsersAppConfig + UsersAppConfig, + HistoryAppConfig ]; /** diff --git a/client/src/app/core/services/data-store.service.ts b/client/src/app/core/services/data-store.service.ts index 844c76409..bbd2537b7 100644 --- a/client/src/app/core/services/data-store.service.ts +++ b/client/src/app/core/services/data-store.service.ts @@ -330,12 +330,25 @@ export class DataStoreService { /** * Resets the DataStore and set the given models as the new content. * @param models A list of models to set the DataStore to. - * @param newMaxChangeId Optional. If given, the max change id will be updated. + * @param newMaxChangeId Optional. If given, the max change id will be updated + * and the store flushed to the storage */ - public async set(models: BaseModel[], newMaxChangeId?: number): Promise { + public async set(models?: BaseModel[], newMaxChangeId?: number): Promise { + const modelStoreReference = this.modelStore; this.modelStore = {}; this.jsonStore = {}; - await this.add(models, newMaxChangeId); + // Inform about the deletion + Object.keys(modelStoreReference).forEach(collectionString => { + Object.keys(modelStoreReference[collectionString]).forEach(id => { + this.deletedSubject.next({ + collection: collectionString, + id: +id + }); + }) + }); + if (models && models.length) { + await this.add(models, newMaxChangeId); + } } /** diff --git a/client/src/app/core/services/http.service.ts b/client/src/app/core/services/http.service.ts index 5a549858c..79abaa0f3 100644 --- a/client/src/app/core/services/http.service.ts +++ b/client/src/app/core/services/http.service.ts @@ -1,6 +1,8 @@ import { Injectable } from '@angular/core'; import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; +import { formatQueryParams, QueryParams } from '../query-params'; +import { OpenSlidesStatusService } from './openslides-status.service'; /** * Enum for different HTTPMethods @@ -32,34 +34,45 @@ export class HttpService { * * @param http The HTTP Client * @param translate + * @param timeTravel requests are only allowed if history mode is disabled */ - public constructor(private http: HttpClient, private translate: TranslateService) { - this.defaultHeaders = new HttpHeaders().set('Content-Type', 'application/json') + public constructor( + private http: HttpClient, + private translate: TranslateService, + private OSStatus: OpenSlidesStatusService + ) { + this.defaultHeaders = new HttpHeaders().set('Content-Type', 'application/json'); } /** - * Send the a http request the the given URL. + * Send the a http request the the given path. * Optionally accepts a request body. * - * @param url the target url, usually starting with /rest + * @param path the target path, usually starting with /rest * @param method the required HTTP method (i.e get, post, put) * @param data optional, if sending a data body is required + * @param queryParams optional queryparams to append to the path * @param customHeader optional custom HTTP header of required * @returns a promise containing a generic */ - private async send(url: string, method: HTTPMethod, data?: any, customHeader?: HttpHeaders): Promise { - if (!url.endsWith('/')) { - url += '/'; + private async send(path: string, method: HTTPMethod, data?: any, queryParams?: QueryParams, customHeader?: HttpHeaders): Promise { + // end early, if we are in history mode + if (this.OSStatus.isInHistoryMode && method !== HTTPMethod.GET) { + throw this.handleError('You cannot make changes while in history mode'); } + if (!path.endsWith('/')) { + path += '/'; + } + + const url = path + formatQueryParams(queryParams); const options = { body: data, headers: customHeader ? customHeader : this.defaultHeaders }; try { - const response = await this.http.request(method, url, options).toPromise(); - return response; + return await this.http.request(method, url, options).toPromise(); } catch (e) { throw this.handleError(e); } @@ -73,6 +86,12 @@ export class HttpService { */ private handleError(e: any): string { let error = this.translate.instant('Error') + ': '; + + // If the rror is a string already, return it. + if (typeof e === 'string') { + return error + e; + } + // If the error is no HttpErrorResponse, it's not clear what is wrong. if (!(e instanceof HttpErrorResponse)) { console.error('Unknown error thrown by the http client: ', e); @@ -119,57 +138,62 @@ export class HttpService { } /** - * Exectures a get on a url with a certain object - * @param url The url to send the request to. + * Executes a get on a path with a certain object + * @param path The path to send the request to. * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async get(url: string, data?: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.GET, data, header); + public async get(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.GET, data, queryParams, header); } /** - * Exectures a post on a url with a certain object - * @param url The url to send the request to. + * Executes a post on a path with a certain object + * @param path The path to send the request to. * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async post(url: string, data?: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.POST, data, header); + public async post(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.POST, data, queryParams, header); } /** - * Exectures a put on a url with a certain object - * @param url The url to send the request to. - * @param data The payload for the request. + * Executes a put on a path with a certain object + * @param path The path to send the request to. + * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async patch(url: string, data: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.PATCH, data, header); + public async patch(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.PATCH, data, queryParams, header); } /** - * Exectures a put on a url with a certain object - * @param url The url to send the request to. - * @param data: The payload for the request. + * Executes a put on a path with a certain object + * @param path The path to send the request to. + * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async put(url: string, data: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.PUT, data, header); + public async put(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.PUT, data, queryParams, header); } /** * Makes a delete request. - * @param url The url to send the request to. - * @param data An optional data to send in the requestbody. + * @param url The path to send the request to. + * @param data An optional payload for the request. + * @param queryParams Optional params appended to the path as the query part of the url. * @param header optional HTTP header if required * @returns A promise holding a generic */ - public async delete(url: string, data?: any, header?: HttpHeaders): Promise { - return await this.send(url, HTTPMethod.DELETE, data, header); + public async delete(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise { + return await this.send(path, HTTPMethod.DELETE, data, queryParams, header); } } diff --git a/client/src/app/core/services/openslides-status.service.spec.ts b/client/src/app/core/services/openslides-status.service.spec.ts new file mode 100644 index 000000000..aae2d5e9b --- /dev/null +++ b/client/src/app/core/services/openslides-status.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { OpenSlidesStatusService } from './openslides-status.service'; + +describe('OpenSlidesStatusService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [OpenSlidesStatusService] + }); + }); + + it('should be created', inject([OpenSlidesStatusService], (service: OpenSlidesStatusService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/core/services/openslides-status.service.ts b/client/src/app/core/services/openslides-status.service.ts new file mode 100644 index 000000000..28e1db6cb --- /dev/null +++ b/client/src/app/core/services/openslides-status.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@angular/core'; + +/** + * Holds information about OpenSlides. This is not included into other services to + * avoid circular dependencies. + */ +@Injectable({ + providedIn: 'root' +}) +export class OpenSlidesStatusService { + + /** + * Saves, if OpenSlides is in the history mode. + */ + private historyMode = false; + + /** + * Returns, if OpenSlides is in the history mode. + */ + public get isInHistoryMode(): boolean { + return this.historyMode; + } + + /** + * Ctor, does nothing. + */ + public constructor() {} + + /** + * Enters the histroy mode + */ + public enterHistoryMode(): void { + this.historyMode = true; + } + + /** + * Leaves the histroy mode + */ + public leaveHistroyMode(): void { + this.historyMode = false; + } +} diff --git a/client/src/app/core/services/storage.service.ts b/client/src/app/core/services/storage.service.ts index fc56b1d2d..40fd599bc 100644 --- a/client/src/app/core/services/storage.service.ts +++ b/client/src/app/core/services/storage.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { LocalStorage } from '@ngx-pwa/local-storage'; +import { OpenSlidesStatusService } from './openslides-status.service'; /** * Provides an async API to an key-value store using ngx-pwa which is internally @@ -13,7 +14,7 @@ export class StorageService { * Constructor to create the StorageService. Needs the localStorage service. * @param localStorage */ - public constructor(private localStorage: LocalStorage) {} + public constructor(private localStorage: LocalStorage, private OSStatus: OpenSlidesStatusService) {} /** * Sets the item into the store asynchronously. @@ -21,6 +22,7 @@ export class StorageService { * @param item */ public async set(key: string, item: any): Promise { + this.assertNotHistroyMode(); if (item === null || item === undefined) { await this.remove(key); // You cannot do a setItem with null or undefined... } else { @@ -48,6 +50,7 @@ export class StorageService { * @param key The key to remove the value from */ public async remove(key: string): Promise { + this.assertNotHistroyMode(); if (!(await this.localStorage.removeItem(key).toPromise())) { throw new Error('Could not delete the item.'); } @@ -57,9 +60,18 @@ export class StorageService { * Clear the whole cache */ public async clear(): Promise { - console.log('clear storage'); + this.assertNotHistroyMode(); if (!(await this.localStorage.clear().toPromise())) { throw new Error('Could not clear the storage.'); } } + + /** + * Throws an error, if we are in history mode. + */ + private assertNotHistroyMode(): void { + if (this.OSStatus.isInHistoryMode) { + throw new Error('You cannot use the storageService in histroy mode.'); + } + } } diff --git a/client/src/app/core/services/time-travel.service.spec.ts b/client/src/app/core/services/time-travel.service.spec.ts new file mode 100644 index 000000000..60b32fb50 --- /dev/null +++ b/client/src/app/core/services/time-travel.service.spec.ts @@ -0,0 +1,16 @@ +import { TestBed } from '@angular/core/testing'; + +import { TimeTravelService } from './time-travel.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('TimeTravelService', () => { + beforeEach(() => TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [TimeTravelService] + })); + + it('should be created', () => { + const service: TimeTravelService = TestBed.get(TimeTravelService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/core/services/time-travel.service.ts b/client/src/app/core/services/time-travel.service.ts new file mode 100644 index 000000000..dbc37d6e9 --- /dev/null +++ b/client/src/app/core/services/time-travel.service.ts @@ -0,0 +1,119 @@ +import { Injectable } from '@angular/core'; + +import { environment } from 'environments/environment'; +import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; +import { History } from 'app/shared/models/core/history'; +import { DataStoreService } from './data-store.service'; +import { WebsocketService } from './websocket.service'; +import { BaseModel } from 'app/shared/models/base/base-model'; +import { OpenSlidesStatusService } from './openslides-status.service'; +import { OpenSlidesService } from './openslides.service'; +import { HttpService } from './http.service'; + +/** + * Interface for full history data objects. + * The are not too different from the history-objects, + * but contain full-data and a timestamp in contrast to a date + */ +interface HistoryData { + element_id: string; + full_data: BaseModel; + information: string; + timestamp: number; + user_id: number; +} + +/** + * Service to enable browsing OpenSlides in a previous version. + * + * This should stop auto updates, save the current ChangeID and overwrite the DataStore with old Values + * from the servers History. + * + * Restoring is nor possible yet. Simply reload + */ +@Injectable({ + providedIn: 'root' +}) +export class TimeTravelService { + /** + * Constructs the time travel service + * + * @param httpService To fetch the history data + * @param webSocketService to disable websocket connection + * @param modelMapperService to cast history objects into models + * @param DS to overwrite the dataStore + * @param OSStatus Sets the history status + * @param OpenSlides For restarting OpenSlide when exiting the history mode + */ + public constructor( + private httpService: HttpService, + private webSocketService: WebsocketService, + private modelMapperService: CollectionStringModelMapperService, + private DS: DataStoreService, + private OSStatus: OpenSlidesStatusService, + private OpenSlides: OpenSlidesService + ) { } + + /** + * Main entry point to set OpenSlides to another history point. + * + * @param history the desired point in the history of OpenSlides + */ + public async loadHistoryPoint(history: History): Promise { + await this.stopTime(); + const fullDataHistory: HistoryData[] = await this.getHistoryData(history); + for (const historyObject of fullDataHistory) { + let collectionString: string; + let id: string; + [collectionString, id] = historyObject.element_id.split(':') + + if (historyObject.full_data) { + const targetClass = this.modelMapperService.getModelConstructor(collectionString); + await this.DS.add([new targetClass(historyObject.full_data)]) + } else { + await this.DS.remove(collectionString, [+id]); + } + } + } + + /** + * Leaves the history mode. Just restart OpenSlides: + * The active user is chacked, a new WS connection established and + * all missed autoupdates are requested. + */ + public async resumeTime(): Promise { + await this.DS.set(); + await this.OpenSlides.reboot(); + this.OSStatus.leaveHistroyMode(); + } + + /** + * Read the history on a given time + * + * @param date the Date object + * @returns the full history on the given date + */ + private async getHistoryData(history: History): Promise { + const historyUrl = '/core/history/' + const queryParams = { timestamp: Math.ceil(+history.unixtime) }; + return this.httpService.get(environment.urlPrefix + historyUrl, null, queryParams); + } + + /** + * Clears the DataStore and stops the WebSocket connection + */ + private async stopTime(): Promise { + this.webSocketService.close(); + await this.cleanDataStore(); + this.OSStatus.enterHistoryMode(); + } + + /** + * Clean the DataStore to inject old Data. + * Remove everything "but" the history. + */ + private async cleanDataStore(): Promise { + const historyArchive = this.DS.getAll(History); + await this.DS.set(historyArchive); + } +} diff --git a/client/src/app/core/services/websocket.service.ts b/client/src/app/core/services/websocket.service.ts index 624830e81..dcf15f742 100644 --- a/client/src/app/core/services/websocket.service.ts +++ b/client/src/app/core/services/websocket.service.ts @@ -2,15 +2,7 @@ import { Injectable, NgZone, EventEmitter } from '@angular/core'; import { Observable, Subject } from 'rxjs'; import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; import { TranslateService } from '@ngx-translate/core'; - -type QueryParamValue = string | number | boolean; - -/** - * A key value mapping for params, that should be appendet to the url on a new connection. - */ -interface QueryParams { - [key: string]: QueryParamValue; -} +import { formatQueryParams, QueryParams } from '../query-params'; /** * The generic message format in which messages are send and recieved by the server. @@ -116,7 +108,7 @@ export class WebsocketService { // Create the websocket let socketPath = location.protocol === 'https:' ? 'wss://' : 'ws://'; socketPath += window.location.hostname + ':' + window.location.port + '/ws/'; - socketPath += this.formatQueryParams(queryParams); + socketPath += formatQueryParams(queryParams); console.log('connect to', socketPath); this.websocket = new WebSocket(socketPath); @@ -225,24 +217,4 @@ export class WebsocketService { } this.websocket.send(JSON.stringify(message)); } - - /** - * Formats query params for the url. - * @param queryParams - * @returns the formatted query params as string - */ - private formatQueryParams(queryParams: QueryParams = {}): string { - let params = ''; - const keys: string[] = Object.keys(queryParams); - if (keys.length > 0) { - params = - '?' + - keys - .map(key => { - return key + '=' + queryParams[key].toString(); - }) - .join('&'); - } - return params; - } } diff --git a/client/src/app/shared/models/core/history.ts b/client/src/app/shared/models/core/history.ts new file mode 100644 index 000000000..fc9e49018 --- /dev/null +++ b/client/src/app/shared/models/core/history.ts @@ -0,0 +1,39 @@ + +import { BaseModel } from '../base/base-model'; + +/** + * Representation of a history object. + * + * @ignore + */ +export class History extends BaseModel { + public id: number; + public element_id: string; + public now: string; + public information: string; + public user_id: number; + + /** + * return a date our of the given timestamp + * + * @returns a Data object + */ + public get date(): Date { + return new Date(this.now); + } + + /** + * Converts the timestamp to unix time + */ + public get unixtime(): number { + return Date.parse(this.now) / 1000; + } + + public constructor(input?: any) { + super('core/history', input); + } + + public getTitle(): string { + return this.element_id; + } +} diff --git a/client/src/app/site/assignments/services/assignment-repository.service.spec.ts b/client/src/app/site/assignments/services/assignment-repository.service.spec.ts index c694cfae7..97f1dcb96 100644 --- a/client/src/app/site/assignments/services/assignment-repository.service.spec.ts +++ b/client/src/app/site/assignments/services/assignment-repository.service.spec.ts @@ -1,9 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { AssignmentRepositoryService } from './assignment-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; describe('AssignmentRepositoryService', () => { - beforeEach(() => TestBed.configureTestingModule({})); + beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] })); it('should be created', () => { const service: AssignmentRepositoryService = TestBed.get(AssignmentRepositoryService); diff --git a/client/src/app/site/assignments/services/assignment-repository.service.ts b/client/src/app/site/assignments/services/assignment-repository.service.ts index 4d00e5431..c922b410f 100644 --- a/client/src/app/site/assignments/services/assignment-repository.service.ts +++ b/client/src/app/site/assignments/services/assignment-repository.service.ts @@ -21,8 +21,13 @@ export class AssignmentRepositoryService extends BaseRepository = new BehaviorSubject([]); /** + * Construction routine for the base repository * + * @param DS: The DataStore + * @param collectionStringModelMapperService Mapping strings to their corresponding classes * @param baseModelCtor The model constructor of which this repository is about. * @param depsModelCtors A list of constructors that are used in the view model. * If one of those changes, the view models will be updated. @@ -33,7 +37,7 @@ export abstract class BaseRepository, - protected depsModelCtors?: ModelConstructor[] + protected depsModelCtors?: ModelConstructor[], ) { super(); this.setup(); diff --git a/client/src/app/site/base/base-view.ts b/client/src/app/site/base/base-view.ts index 526ee53ee..7c17eb5f3 100644 --- a/client/src/app/site/base/base-view.ts +++ b/client/src/app/site/base/base-view.ts @@ -1,8 +1,10 @@ -import { BaseComponent } from '../../base.component'; -import { Title } from '@angular/platform-browser'; -import { TranslateService } from '@ngx-translate/core'; -import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; import { OnDestroy } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; + +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from '../../base.component'; /** * A base class for all views. Implements a generic error handling by raising a snack bar diff --git a/client/src/app/site/config/services/config-repository.service.ts b/client/src/app/site/config/services/config-repository.service.ts index c7acb7a58..fd0461f6c 100644 --- a/client/src/app/site/config/services/config-repository.service.ts +++ b/client/src/app/site/config/services/config-repository.service.ts @@ -86,6 +86,11 @@ export class ConfigRepositoryService extends BaseRepository /** * Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure. + * + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects + * @param http OpenSlides own HTTP Service */ public constructor( DS: DataStoreService, diff --git a/client/src/app/site/history/components/history-list/history-list.component.html b/client/src/app/site/history/components/history-list/history-list.component.html new file mode 100644 index 000000000..9d4ccc2b4 --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.html @@ -0,0 +1,58 @@ + + +
History
+ + + +
+ + + + + Time + {{ history.getLocaleString('DE-de') }} + + + + + Info + {{ history.information }} + + + + + Element + + + +
{{ getElementInfo(history) }}
+
+ {{ 'No information available' | translate }} ({{ history.element_id }}) +
+
+
+ + + + User + {{ history.user }} + + + + +
+ + + + + + diff --git a/client/src/app/site/history/components/history-list/history-list.component.scss b/client/src/app/site/history/components/history-list/history-list.component.scss new file mode 100644 index 000000000..3e36f60e7 --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.scss @@ -0,0 +1,26 @@ +.os-listview-table { + /** Time */ + .mat-column-time { + flex: 1 0 50px; + } + + /** Element */ + .mat-column-element { + flex: 3 0 50px; + } + + /** Info */ + .mat-column-info { + flex: 1 0 50px; + } + + /** User */ + .mat-column-user { + flex: 1 0 50px; + } +} + +.no-info { + font-style: italic; + color: slategray; // TODO: Colors per theme +} diff --git a/client/src/app/site/history/components/history-list/history-list.component.spec.ts b/client/src/app/site/history/components/history-list/history-list.component.spec.ts new file mode 100644 index 000000000..edd96bac4 --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { HistoryListComponent } from './history-list.component'; + +describe('HistoryListComponent', () => { + let component: HistoryListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [HistoryListComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(HistoryListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/history/components/history-list/history-list.component.ts b/client/src/app/site/history/components/history-list/history-list.component.ts new file mode 100644 index 000000000..5ecd21bba --- /dev/null +++ b/client/src/app/site/history/components/history-list/history-list.component.ts @@ -0,0 +1,112 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; +import { MatSnackBar } from '@angular/material'; +import { Subject } from 'rxjs'; + +import { TranslateService } from '@ngx-translate/core'; + +import { ListViewBaseComponent } from 'app/site/base/list-view-base'; +import { HistoryRepositoryService } from '../../services/history-repository.service'; +import { ViewHistory } from '../../models/view-history'; + +/** + * A list view for the history. + * + * Should display all changes that have been made in OpenSlides. + */ +@Component({ + selector: 'os-history-list', + templateUrl: './history-list.component.html', + styleUrls: ['./history-list.component.scss'] +}) +export class HistoryListComponent extends ListViewBaseComponent implements OnInit { + /** + * Subject determine when the custom timestamp subject changes + */ + public customTimestampChanged: Subject = new Subject(); + + /** + * Constructor for the history list component + * + * @param titleService Setting the title + * @param translate Handle translations + * @param matSnackBar Showing errors and messages + * @param repo The history repository + */ + public constructor( + titleService: Title, + translate: TranslateService, + matSnackBar: MatSnackBar, + private repo: HistoryRepositoryService + ) { + super(titleService, translate, matSnackBar); + } + + /** + * Init function for the history list. + */ + public ngOnInit(): void { + super.setTitle('History'); + this.initTable(); + + this.repo.getViewModelListObservable().subscribe(history => { + this.sortAndPublish(history); + }); + } + + /** + * Sorts the given ViewHistory array and sets it in the table data source + * + * @param unsortedHistoryList + */ + private sortAndPublish(unsortedHistoryList: ViewHistory[]): void { + const sortedList = unsortedHistoryList.map(history => history); + sortedList.sort((a, b) => b.history.unixtime - a.history.unixtime); + this.dataSource.data = sortedList; + } + + /** + * Returns the row definition for the table + * + * @returns an array of strings that contains the required row definition + */ + public getRowDef(): string[] { + return ['time', 'element', 'info', 'user']; + } + + /** + * Tries get the title of the BaseModel element corresponding to + * a history object. + * + * @param history the history + * @returns the title of an old element or null if it could not be found + */ + public getElementInfo(history: ViewHistory): string { + const oldElementTitle = this.repo.getOldModelInfo(history.getCollectionString(), history.getModelID()); + + if (oldElementTitle) { + return oldElementTitle; + } else { + return null; + } + } + + /** + * Click handler for rows in the history table. + * Serves as an entry point for the time travel routine + * + * @param history Represents the selected element + */ + public onClickRow(history: ViewHistory): void { + this.repo.browseHistory(history).then(() => { + this.raiseError(`Temporarily reset OpenSlides to the state from ${history.getLocaleString('DE-de')}`); + }); + } + + /** + * Handler for the delete all button + */ + public onDeleteAllButton(): void { + this.repo.delete(); + } +} diff --git a/client/src/app/site/history/history-routing.module.ts b/client/src/app/site/history/history-routing.module.ts new file mode 100644 index 000000000..558dc44d0 --- /dev/null +++ b/client/src/app/site/history/history-routing.module.ts @@ -0,0 +1,17 @@ +import { NgModule } from '@angular/core'; +import { Routes, RouterModule } from '@angular/router'; +import { HistoryListComponent } from './components/history-list/history-list.component'; + +/** + * Define the routes for the history module + */ +const routes: Routes = [{ path: '', component: HistoryListComponent }]; + +/** + * Define the routing component and setup the routes + */ +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] +}) +export class HistoryRoutingModule {} diff --git a/client/src/app/site/history/history.config.ts b/client/src/app/site/history/history.config.ts new file mode 100644 index 000000000..657b2afa4 --- /dev/null +++ b/client/src/app/site/history/history.config.ts @@ -0,0 +1,20 @@ +import { AppConfig } from '../base/app-config'; +import { History } from 'app/shared/models/core/history'; + +/** + * Config object for history. + * Hooks into the navigation. + */ +export const HistoryAppConfig: AppConfig = { + name: 'history', + models: [{ collectionString: 'core/history', model: History }], + mainMenuEntries: [ + { + route: '/history', + displayName: 'History', + icon: 'history', + weight: 1200, + permission: 'core.view_history' + } + ] +}; diff --git a/client/src/app/site/history/history.module.ts b/client/src/app/site/history/history.module.ts new file mode 100644 index 000000000..a8a2084a9 --- /dev/null +++ b/client/src/app/site/history/history.module.ts @@ -0,0 +1,16 @@ +import { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +import { HistoryRoutingModule } from './history-routing.module'; +import { SharedModule } from '../../shared/shared.module'; +import { HistoryListComponent } from './components/history-list/history-list.component'; + +/** + * App module for the history feature. + * Declares the used components. + */ +@NgModule({ + imports: [CommonModule, HistoryRoutingModule, SharedModule], + declarations: [HistoryListComponent] +}) +export class HistoryModule {} diff --git a/client/src/app/site/history/models/view-history.ts b/client/src/app/site/history/models/view-history.ts new file mode 100644 index 000000000..1018497fe --- /dev/null +++ b/client/src/app/site/history/models/view-history.ts @@ -0,0 +1,132 @@ +import { BaseViewModel } from 'app/site/base/base-view-model'; +import { History } from 'app/shared/models/core/history'; +import { User } from 'app/shared/models/users/user'; +import { BaseModel } from 'app/shared/models/base/base-model'; + +/** + * View model for history objects + */ +export class ViewHistory extends BaseViewModel { + /** + * Private BaseModel of the history + */ + private _history: History; + + /** + * Real representation of the user who altered the history. + * Determined from `History.user_id` + */ + private _user: User; + + /** + * Read the history property + */ + public get history(): History { + return this._history ? this._history : null; + } + + /** + * Read the user property + */ + public get user(): User { + return this._user ? this._user : null; + } + + /** + * Get the ID of the history object + * Required by BaseViewModel + * + * @returns the ID as number + */ + public get id(): number { + return this.history ? this.history.id : null; + } + + /** + * Get the elementIs of the history object + * + * @returns the element ID as String + */ + public get element_id(): string { + return this.history ? this.history.element_id : null; + } + + /** + * Get the information about the history + * + * @returns a string with the information to the history object + */ + public get information(): string { + return this.history ? this.history.information : null; + } + + /** + * Get the time of the history as number + * + * @returns the unix timestamp as number + */ + public get now(): string { + return this.history ? this.history.now : null; + } + + /** + * Construction of a ViewHistory + * + * @param history the real history BaseModel + * @param user the real user BaseModel + */ + public constructor(history?: History, user?: User) { + super(); + this._history = history; + this._user = user; + } + + /** + * Converts the date (this.now) to a time and date string. + * + * @param locale locale indicator, i.e 'de-DE' + * @returns a human readable kind of time and date representation + */ + public getLocaleString(locale: string): string { + return this.history.date ? this.history.date.toLocaleString(locale) : null; + } + + /** + * Converts elementID into collection string + * @returns the CollectionString to the model + */ + public getCollectionString(): string { + return this.element_id.split(":")[0] + } + + /** + * Extract the models ID from the elementID + * @returns a model id + */ + public getModelID(): number { + return +this.element_id.split(":")[1] + } + + /** + * Get the history objects title + * Required by BaseViewModel + * + * @returns history.getTitle which returns the element_id + */ + public getTitle(): string { + return this.history.getTitle(); + } + + /** + * Updates the history object with new values + * + * @param update potentially the new values for history or it's components. + */ + public updateValues(update: BaseModel): void { + if (update instanceof History && this.history.id === update.id) { + this._history = update; + } else if (this.history && update instanceof User && this.history.user_id === update.id) { + this._user = update; + } + } +} diff --git a/client/src/app/site/history/services/history-repository.service.spec.ts b/client/src/app/site/history/services/history-repository.service.spec.ts new file mode 100644 index 000000000..fc8a86317 --- /dev/null +++ b/client/src/app/site/history/services/history-repository.service.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; + +import { HistoryRepositoryService } from './history-repository.service'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('HistoryRepositoryService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [HistoryRepositoryService] + }); + }); + + it('should be created', () => { + const service = TestBed.get(HistoryRepositoryService); + expect(service).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/history/services/history-repository.service.ts b/client/src/app/site/history/services/history-repository.service.ts new file mode 100644 index 000000000..36796e7ce --- /dev/null +++ b/client/src/app/site/history/services/history-repository.service.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@angular/core'; + +import { CollectionStringModelMapperService } from 'app/core/services/collectionStringModelMapper.service'; +import { DataStoreService } from 'app/core/services/data-store.service'; +import { BaseRepository } from 'app/site/base/base-repository'; +import { History } from 'app/shared/models/core/history'; +import { User } from 'app/shared/models/users/user'; +import { Identifiable } from 'app/shared/models/base/identifiable'; +import { HttpService } from 'app/core/services/http.service'; +import { ViewHistory } from '../models/view-history'; +import { TimeTravelService } from 'app/core/services/time-travel.service'; +import { BaseModel } from 'app/shared/models/base/base-model'; + +/** + * Repository for the history. + * + * Gets new history objects/entries and provides them for the view. + */ +@Injectable({ + providedIn: 'root' +}) +export class HistoryRepositoryService extends BaseRepository { + /** + * Constructs the history repository + * + * @param DS The DataStore + * @param mapperService mapps the models to the collection string + * @param httpService OpenSlides own HTTP service + * @param timeTravel To change the time + */ + public constructor( + DS: DataStoreService, + mapperService: CollectionStringModelMapperService, + private httpService: HttpService, + private timeTravel: TimeTravelService + ) { + super(DS, mapperService, History, [User]); + } + + /** + * Clients usually do not need to create a history object themselves + * @ignore + */ + public async create(): Promise { + throw new Error('You cannot create a history object'); + } + + /** + * Clients usually do not need to modify existing history objects + * @ignore + */ + public async update(): Promise { + throw new Error('You cannot update a history object'); + } + + /** + * Sends a post-request to delete history objects + */ + public async delete(): Promise { + const restPath = 'rest/core/history/clear_history/'; + await this.httpService.post(restPath); + } + + /** + * Get the ListTitle of a history Element from the dataStore + * using the collection string and the ID. + * + * @param collectionString the models collection string + * @param id the models id + * @returns the ListTitle or null if the model was deleted already + */ + public getOldModelInfo(collectionString: string, id: number): string { + const oldModel: BaseModel = this.DS.get(collectionString, id); + if (oldModel) { + return oldModel.getListTitle(); + } else { + return null; + } + } + + /** + * Creates a new ViewHistory objects out of a historyObject + * + * @param history the source history object + * @return a new ViewHistory object + */ + public createViewModel(history: History): ViewHistory { + const user = this.DS.get(User, history.user_id); + return new ViewHistory(history, user); + } + + /** + * Get the full data on the given date and use the + * TimeTravelService to browse the history on the + * given date + * + * @param viewHistory determines to point to travel back to + */ + public async browseHistory(viewHistory: ViewHistory): Promise { + return this.timeTravel.loadHistoryPoint(viewHistory.history); + } +} diff --git a/client/src/app/site/mediafiles/services/mediafile-repository.service.ts b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts index a8c0b56e7..0b6e63d91 100644 --- a/client/src/app/site/mediafiles/services/mediafile-repository.service.ts +++ b/client/src/app/site/mediafiles/services/mediafile-repository.service.ts @@ -78,7 +78,7 @@ export class MediafileRepositoryService extends BaseRepository { const restPath = `rest/mediafiles/mediafile/`; const emptyHeader = new HttpHeaders(); - return this.httpService.post(restPath, file, emptyHeader); + return this.httpService.post(restPath, file, {}, emptyHeader); } /** diff --git a/client/src/app/site/motions/services/category-repository.service.ts b/client/src/app/site/motions/services/category-repository.service.ts index ec966d5ea..976d0650b 100644 --- a/client/src/app/site/motions/services/category-repository.service.ts +++ b/client/src/app/site/motions/services/category-repository.service.ts @@ -27,9 +27,11 @@ export class CategoryRepositoryService extends BaseRepository { - this.configMinSupporters = supporters; - } - ); + this.configService.get('motions_min_supporters').subscribe(supporters => (this.configMinSupporters = supporters)); } /** diff --git a/client/src/app/site/motions/services/motion-repository.service.ts b/client/src/app/site/motions/services/motion-repository.service.ts index 15bd26d1a..721763431 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -40,15 +40,19 @@ import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragr providedIn: 'root' }) export class MotionRepositoryService extends BaseRepository { + /** * Creates a MotionRepository * * Converts existing and incoming motions to ViewMotions * Handles CRUD using an observer to the DataStore - * @param {DataStoreService} DS - * @param {DataSendService} dataSend - * @param {LinenumberingService} lineNumbering - * @param {DiffService} diff + * + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects + * @param httpService OpenSlides own Http service + * @param lineNumbering Line numbering for motion text + * @param diff Display changes in motion text as diff. */ public constructor( DS: DataStoreService, diff --git a/client/src/app/site/motions/services/statute-paragraph-repository.service.ts b/client/src/app/site/motions/services/statute-paragraph-repository.service.ts index c3625c79f..b010f2442 100644 --- a/client/src/app/site/motions/services/statute-paragraph-repository.service.ts +++ b/client/src/app/site/motions/services/statute-paragraph-repository.service.ts @@ -22,7 +22,10 @@ export class StatuteParagraphRepositoryService extends BaseRepository + You are using the history mode of OpenSlides. Changes will not be saved. + Exit + diff --git a/client/src/app/site/site.component.scss b/client/src/app/site/site.component.scss index 248309967..fe739fe2e 100644 --- a/client/src/app/site/site.component.scss +++ b/client/src/app/site/site.component.scss @@ -15,8 +15,7 @@ } .os-logo-container:focus, .os-logo-container:active, -.os-logo-container:hover, - { +.os-logo-container:hover { border: none; outline: none; } @@ -42,6 +41,31 @@ mat-sidenav-container { padding-bottom: 70px; } +.history-mode-indicator { + position: fixed; + width: 100%; + z-index: 10; + background: repeating-linear-gradient(45deg, #ffee00, #ffee00 10px, #070600 10px, #000000 20px); + text-align: center; + line-height: 20px; + height: 20px; + + span { + padding: 2px; + color: #000000; + background: #ffee00; + } + + a { + padding: 2px; + cursor: pointer; + font-weight: bold; + text-decoration: none; + background: #ffee00; + color: #000000; + } +} + main { display: flex; flex-direction: column; diff --git a/client/src/app/site/site.component.ts b/client/src/app/site/site.component.ts index 81b7e1102..d6f5d584b 100644 --- a/client/src/app/site/site.component.ts +++ b/client/src/app/site/site.component.ts @@ -10,6 +10,8 @@ import { pageTransition, navItemAnim } from 'app/shared/animations'; import { MatDialog, MatSidenav } from '@angular/material'; import { ViewportService } from '../core/services/viewport.service'; import { MainMenuService } from '../core/services/main-menu.service'; +import { OpenSlidesStatusService } from 'app/core/services/openslides-status.service'; +import { TimeTravelService } from 'app/core/services/time-travel.service'; @Component({ selector: 'os-site', @@ -48,11 +50,14 @@ export class SiteComponent extends BaseComponent implements OnInit { * Constructor * * @param authService - * @param router + * @param route * @param operator * @param vp * @param translate * @param dialog + * @param mainMenuService + * @param OSStatus + * @param timeTravel */ public constructor( private authService: AuthService, @@ -61,7 +66,9 @@ export class SiteComponent extends BaseComponent implements OnInit { public vp: ViewportService, public translate: TranslateService, public dialog: MatDialog, - public mainMenuService: MainMenuService // used in the component + public mainMenuService: MainMenuService, + public OSStatus: OpenSlidesStatusService, + public timeTravel: TimeTravelService ) { super(); diff --git a/client/src/app/site/tags/services/tag-repository.service.ts b/client/src/app/site/tags/services/tag-repository.service.ts index 902574335..2b1dbc635 100644 --- a/client/src/app/site/tags/services/tag-repository.service.ts +++ b/client/src/app/site/tags/services/tag-repository.service.ts @@ -25,7 +25,10 @@ export class TagRepositoryService extends BaseRepository { * Creates a TagRepository * Converts existing and incoming Tags to ViewTags * Handles CRUD using an observer to the DataStore - * @param DataSend + * + * @param DS DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects */ public constructor( protected DS: DataStoreService, diff --git a/client/src/app/site/users/services/group-repository.service.ts b/client/src/app/site/users/services/group-repository.service.ts index 9ea9b17a8..7a591005c 100644 --- a/client/src/app/site/users/services/group-repository.service.ts +++ b/client/src/app/site/users/services/group-repository.service.ts @@ -33,8 +33,10 @@ export class GroupRepositoryService extends BaseRepository { /** * Constructor calls the parent constructor - * @param DS Store - * @param dataSend Sending Data + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects + * @param constants reading out the OpenSlides constants */ public constructor( DS: DataStoreService, diff --git a/client/src/app/site/users/services/user-repository.service.ts b/client/src/app/site/users/services/user-repository.service.ts index 33acc7ec6..bed081c51 100644 --- a/client/src/app/site/users/services/user-repository.service.ts +++ b/client/src/app/site/users/services/user-repository.service.ts @@ -19,7 +19,11 @@ import { CollectionStringModelMapperService } from '../../../core/services/colle }) export class UserRepositoryService extends BaseRepository { /** - * Constructor calls the parent constructor + * Constructor for the user repo + * + * @param DS The DataStore + * @param mapperService Maps collection strings to classes + * @param dataSend sending changed objects */ public constructor( DS: DataStoreService, diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index cb4911519..ffaefacd1 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -26,7 +26,7 @@ def create_builtin_workflows(sender, **kwargs): workflow=workflow_1, action_word='Accept', recommendation_label='Acceptance', - css_class='success'), + css_class='success', merge_amendment_into_final=True) state_1_2.save(skip_autoupdate=True) state_1_3 = State(name=ugettext_noop('rejected'), @@ -64,7 +64,7 @@ def create_builtin_workflows(sender, **kwargs): workflow=workflow_2, action_word='Accept', recommendation_label='Acceptance', - css_class='success'), + css_class='success', merge_amendment_into_final=True) state_2_3.save(skip_autoupdate=True) state_2_4 = State(name=ugettext_noop('rejected'),