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
This commit is contained in:
Sean Engelhardt 2018-11-09 13:44:39 +01:00
parent 060856628b
commit 0c62c1c864
40 changed files with 986 additions and 101 deletions

View File

@ -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;
}

View File

@ -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
];
/**

View File

@ -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<void> {
public async set(models?: BaseModel[], newMaxChangeId?: number): Promise<void> {
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);
}
}
/**

View File

@ -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<T>(url: string, method: HTTPMethod, data?: any, customHeader?: HttpHeaders): Promise<T> {
if (!url.endsWith('/')) {
url += '/';
private async send<T>(path: string, method: HTTPMethod, data?: any, queryParams?: QueryParams, customHeader?: HttpHeaders): Promise<T> {
// 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<T>(method, url, options).toPromise();
return response;
return await this.http.request<T>(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<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.GET, data, header);
public async get<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.POST, data, header);
public async post<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.PATCH, data, header);
public async patch<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.PUT, data, header);
public async put<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(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<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.DELETE, data, header);
public async delete<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(path, HTTPMethod.DELETE, data, queryParams, header);
}
}

View File

@ -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();
}));
});

View File

@ -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;
}
}

View File

@ -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<void> {
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<void> {
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<void> {
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.');
}
}
}

View File

@ -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();
});
});

View File

@ -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<void> {
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<void> {
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<HistoryData[]> {
const historyUrl = '/core/history/'
const queryParams = { timestamp: Math.ceil(+history.unixtime) };
return this.httpService.get<HistoryData[]>(environment.urlPrefix + historyUrl, null, queryParams);
}
/**
* Clears the DataStore and stops the WebSocket connection
*/
private async stopTime(): Promise<void> {
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<void> {
const historyArchive = this.DS.getAll(History);
await this.DS.set(historyArchive);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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);

View File

@ -21,8 +21,13 @@ export class AssignmentRepositoryService extends BaseRepository<ViewAssignment,
/**
* Constructor for the Assignment Repository.
*
* @param DS The DataStore
* @param mapperService Maps collection strings to classes
*/
public constructor(DS: DataStoreService, mapperService: CollectionStringModelMapperService) {
public constructor(
DS: DataStoreService,
mapperService: CollectionStringModelMapperService
) {
super(DS, mapperService, Assignment, [User, Item, Tag]);
}

View File

@ -1,5 +1,6 @@
import { OpenSlidesComponent } from '../../openslides.component';
import { BehaviorSubject, Observable } from 'rxjs';
import { OpenSlidesComponent } from '../../openslides.component';
import { BaseViewModel } from './base-view-model';
import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model';
import { CollectionStringModelMapperService } from '../../core/services/collectionStringModelMapper.service';
@ -24,7 +25,10 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode
protected readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
/**
* 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<V extends BaseViewModel, M extends BaseMode
protected DS: DataStoreService,
protected collectionStringModelMapperService: CollectionStringModelMapperService,
protected baseModelCtor: ModelConstructor<M>,
protected depsModelCtors?: ModelConstructor<BaseModel>[]
protected depsModelCtors?: ModelConstructor<BaseModel>[],
) {
super();
this.setup();

View File

@ -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

View File

@ -86,6 +86,11 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config>
/**
* 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,

View File

@ -0,0 +1,58 @@
<os-head-bar>
<!-- Title -->
<div class="title-slot">History</div>
<!-- Menu -->
<div class="menu-slot">
<button type="button" mat-icon-button [matMenuTriggerFor]="historyMenu"><mat-icon>more_vert</mat-icon></button>
</div>
</os-head-bar>
<mat-table class="os-listview-table on-transition-fade" [dataSource]="dataSource" matSort>
<!-- Timestamp -->
<ng-container matColumnDef="time">
<mat-header-cell *matHeaderCellDef mat-sort-header>Time</mat-header-cell>
<mat-cell *matCellDef="let history"> {{ history.getLocaleString('DE-de') }} </mat-cell>
</ng-container>
<!-- Info -->
<ng-container matColumnDef="info">
<mat-header-cell *matHeaderCellDef mat-sort-header>Info</mat-header-cell>
<mat-cell *matCellDef="let history"> {{ history.information }} </mat-cell>
</ng-container>
<!-- Element -->
<ng-container matColumnDef="element">
<mat-header-cell *matHeaderCellDef mat-sort-header>Element</mat-header-cell>
<!-- <mat-cell *matCellDef="let history"> {{ history.element_id }} </mat-cell> -->
<mat-cell *matCellDef="let history">
<div *ngIf="getElementInfo(history)">{{ getElementInfo(history) }}</div>
<div
class="no-info"
matTooltip="{{ 'Select an entry where information at this time was still present' | translate }}"
*ngIf="!getElementInfo(history)"
>
<span>{{ 'No information available' | translate }} ({{ history.element_id }})</span>
</div>
</mat-cell>
</ng-container>
<!-- User -->
<ng-container matColumnDef="user">
<mat-header-cell *matHeaderCellDef mat-sort-header>User</mat-header-cell>
<mat-cell *matCellDef="let history"> {{ history.user }} </mat-cell>
</ng-container>
<mat-header-row *matHeaderRowDef="getRowDef()"></mat-header-row>
<mat-row *matRowDef="let row; columns: getRowDef()" (click)="onClickRow(row)"></mat-row>
</mat-table>
<mat-paginator class="on-transition-fade" [pageSizeOptions]="[25, 50, 75, 100, 125]"></mat-paginator>
<mat-menu #historyMenu="matMenu">
<button mat-menu-item class="red-warning-text" (click)="onDeleteAllButton()">
<mat-icon>delete</mat-icon>
<span translate>Delete whole history</span>
</button>
</mat-menu>

View File

@ -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
}

View File

@ -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<HistoryListComponent>;
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();
});
});

View File

@ -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<ViewHistory> implements OnInit {
/**
* Subject determine when the custom timestamp subject changes
*/
public customTimestampChanged: Subject<number> = new Subject<number>();
/**
* 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();
}
}

View File

@ -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 {}

View File

@ -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'
}
]
};

View File

@ -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 {}

View File

@ -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;
}
}
}

View File

@ -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();
});
});

View File

@ -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<ViewHistory, History> {
/**
* 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<Identifiable> {
throw new Error('You cannot create a history object');
}
/**
* Clients usually do not need to modify existing history objects
* @ignore
*/
public async update(): Promise<void> {
throw new Error('You cannot update a history object');
}
/**
* Sends a post-request to delete history objects
*/
public async delete(): Promise<void> {
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<void> {
return this.timeTravel.loadHistoryPoint(viewHistory.history);
}
}

View File

@ -78,7 +78,7 @@ export class MediafileRepositoryService extends BaseRepository<ViewMediafile, Me
public async uploadFile(file: FormData): Promise<Identifiable> {
const restPath = `rest/mediafiles/mediafile/`;
const emptyHeader = new HttpHeaders();
return this.httpService.post<Identifiable>(restPath, file, emptyHeader);
return this.httpService.post<Identifiable>(restPath, file, {}, emptyHeader);
}
/**

View File

@ -27,9 +27,11 @@ export class CategoryRepositoryService extends BaseRepository<ViewCategory, Cate
* Creates a CategoryRepository
* Converts existing and incoming category to ViewCategories
* Handles CRUD using an observer to the DataStore
* @param DS
* @param dataSend
* @param httpService
*
* @param DS The DataStore
* @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects
* @param httpService OpenSlides own HTTP service
*/
public constructor(
protected DS: DataStoreService,

View File

@ -32,8 +32,10 @@ export class ChangeRecommendationRepositoryService extends BaseRepository<ViewCh
*
* Converts existing and incoming motions to ViewMotions
* Handles CRUD using an observer to the DataStore
* @param DS
* @param dataSend
*
* @param DS The DataStore
* @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects
*/
public constructor(
DS: DataStoreService,

View File

@ -16,11 +16,7 @@ export class LocalPermissionsService {
private configService: ConfigService,
) {
// load config variables
this.configService.get('motions_min_supporters').subscribe(
(supporters: number): void => {
this.configMinSupporters = supporters;
}
);
this.configService.get('motions_min_supporters').subscribe(supporters => (this.configMinSupporters = supporters));
}
/**

View File

@ -40,15 +40,19 @@ import { ViewMotionAmendedParagraph } from '../models/view-motion-amended-paragr
providedIn: 'root'
})
export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion> {
/**
* 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,

View File

@ -22,7 +22,10 @@ export class StatuteParagraphRepositoryService extends BaseRepository<ViewStatut
* Creates a StatuteParagraphRepository
* Converts existing and incoming statute paragraphs to ViewStatuteParagraphs
* Handles CRUD using an observer to the DataStore
* @param DataSend
*
* @param DS The DataStore
* @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects
*/
public constructor(
DS: DataStoreService,

View File

@ -46,6 +46,10 @@ const routes: Routes = [
{
path: 'tags',
loadChildren: './tags/tag.module#TagModule'
},
{
path: 'history',
loadChildren: './history/history.module#HistoryModule'
}
],
canActivateChild: [AuthGuard]

View File

@ -1,3 +1,7 @@
<div class="history-mode-indicator" *ngIf="OSStatus.isInHistoryMode">
<span translate>You are using the history mode of OpenSlides. Changes will not be saved.</span>
<a (click)="timeTravel.resumeTime()" translate>Exit</a>
</div>
<mat-sidenav-container #siteContainer class='main-container' (backdropClick)="toggleSideNav()">
<mat-sidenav #sideNav [mode]="vp.isMobile ? 'push' : 'side'" [opened]='!vp.isMobile' disableClose='!vp.isMobile'
class="side-panel">

View File

@ -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;

View File

@ -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();

View File

@ -25,7 +25,10 @@ export class TagRepositoryService extends BaseRepository<ViewTag, Tag> {
* 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,

View File

@ -33,8 +33,10 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group> {
/**
* 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,

View File

@ -19,7 +19,11 @@ import { CollectionStringModelMapperService } from '../../../core/services/colle
})
export class UserRepositoryService extends BaseRepository<ViewUser, User> {
/**
* 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,

View File

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