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'),