diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 38443a582..c10d3e073 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,7 @@ Core: - Add a change-id system to get only new elements [#3938]. - Switch from Yarn back to npm [#3964]. - Added password reset link (password reset via email) [#3914]. + - Added global history mode [#3977]. Agenda: - Added viewpoint to assign multiple items to a new parent item [#4037]. 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 229531136..a01d8c5a5 100644 --- a/client/src/app/site/motions/services/category-repository.service.ts +++ b/client/src/app/site/motions/services/category-repository.service.ts @@ -28,9 +28,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 9fe06bfa1..19511c777 100644 --- a/client/src/app/site/motions/services/motion-repository.service.ts +++ b/client/src/app/site/motions/services/motion-repository.service.ts @@ -41,15 +41,19 @@ import { CreateMotion } from '../models/create-motion'; 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 81264e13d..4612abd18 100644 --- a/client/src/app/site/tags/services/tag-repository.service.ts +++ b/client/src/app/site/tags/services/tag-repository.service.ts @@ -26,7 +26,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 a40498a30..74d779055 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 5f9e5d1c6..ab309b5d6 100644 --- a/client/src/app/site/users/services/user-repository.service.ts +++ b/client/src/app/site/users/services/user-repository.service.ts @@ -21,7 +21,11 @@ import { TranslateService } from '@ngx-translate/core'; }) 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/core/access_permissions.py b/openslides/core/access_permissions.py index 2e2276465..1a8ce04f5 100644 --- a/openslides/core/access_permissions.py +++ b/openslides/core/access_permissions.py @@ -1,4 +1,5 @@ from ..utils.access_permissions import BaseAccessPermissions +from ..utils.auth import GROUP_ADMIN_PK, async_in_some_groups class ProjectorAccessPermissions(BaseAccessPermissions): @@ -88,3 +89,24 @@ class ConfigAccessPermissions(BaseAccessPermissions): from .serializers import ConfigSerializer return ConfigSerializer + + +class HistoryAccessPermissions(BaseAccessPermissions): + """ + Access permissions container for the Histroy. + """ + + async def async_check_permissions(self, user_id: int) -> bool: + """ + Returns True if the user is in admin group and has read access to + model instances. + """ + return await async_in_some_groups(user_id, [GROUP_ADMIN_PK]) + + def get_serializer_class(self, user=None): + """ + Returns serializer class. + """ + from .serializers import HistorySerializer + + return HistorySerializer diff --git a/openslides/core/apps.py b/openslides/core/apps.py index a4cf3c05e..5fc3ac3e3 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -32,6 +32,7 @@ class CoreAppConfig(AppConfig): ChatMessageViewSet, ConfigViewSet, CountdownViewSet, + HistoryViewSet, ProjectorMessageViewSet, ProjectorViewSet, TagViewSet, @@ -81,10 +82,12 @@ class CoreAppConfig(AppConfig): router.register(self.get_model('ConfigStore').get_collection_string(), ConfigViewSet, 'config') router.register(self.get_model('ProjectorMessage').get_collection_string(), ProjectorMessageViewSet) router.register(self.get_model('Countdown').get_collection_string(), CountdownViewSet) + router.register(self.get_model('History').get_collection_string(), HistoryViewSet) - # Sets the cache + # Sets the cache and builds the startup history if is_normal_server_start: element_cache.ensure_cache() + self.get_model('History').objects.build_history() # Register client messages register_client_message(NotifyWebsocketClientMessage()) @@ -104,7 +107,7 @@ class CoreAppConfig(AppConfig): Yields all Cachables required on startup i. e. opening the websocket connection. """ - for model_name in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown', 'ConfigStore'): + for model_name in ('Projector', 'ChatMessage', 'Tag', 'ProjectorMessage', 'Countdown', 'ConfigStore', 'History'): yield self.get_model(model_name) def get_angular_constants(self): diff --git a/openslides/core/migrations/0009_auto_20181118_2126.py b/openslides/core/migrations/0009_auto_20181118_2126.py new file mode 100644 index 000000000..3c7f2ff3f --- /dev/null +++ b/openslides/core/migrations/0009_auto_20181118_2126.py @@ -0,0 +1,54 @@ +# Generated by Django 2.1.3 on 2018-11-18 20:26 + +import django.db.models.deletion +import jsonfield.encoder +import jsonfield.fields +from django.conf import settings +from django.db import migrations, models + +import openslides.utils.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('core', '0008_changed_logo_fields'), + ] + + operations = [ + migrations.CreateModel( + name='History', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('element_id', models.CharField(max_length=255)), + ('now', models.DateTimeField(auto_now_add=True)), + ('information', models.CharField(max_length=255)), + ], + options={ + 'default_permissions': (), + }, + bases=(openslides.utils.models.RESTModelMixin, models.Model), + ), + migrations.CreateModel( + name='HistoryData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_data', jsonfield.fields.JSONField( + dump_kwargs={'cls': jsonfield.encoder.JSONEncoder, 'separators': (',', ':')}, load_kwargs={})), + ], + options={ + 'default_permissions': (), + }, + ), + migrations.AddField( + model_name='history', + name='full_data', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='core.HistoryData'), + ), + migrations.AddField( + model_name='history', + name='user', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/openslides/core/models.py b/openslides/core/models.py index 0c5af53f1..394dc241d 100644 --- a/openslides/core/models.py +++ b/openslides/core/models.py @@ -1,14 +1,18 @@ +from asgiref.sync import async_to_sync from django.conf import settings -from django.db import models +from django.db import models, transaction from django.utils.timezone import now from jsonfield import JSONField +from ..utils.autoupdate import Element +from ..utils.cache import element_cache, get_element_id from ..utils.models import RESTModelMixin from ..utils.projector import get_all_projector_elements from .access_permissions import ( ChatMessageAccessPermissions, ConfigAccessPermissions, CountdownAccessPermissions, + HistoryAccessPermissions, ProjectorAccessPermissions, ProjectorMessageAccessPermissions, TagAccessPermissions, @@ -324,3 +328,100 @@ class Countdown(RESTModelMixin, models.Model): self.running = False self.countdown_time = self.default_time self.save(skip_autoupdate=skip_autoupdate) + + +class HistoryData(models.Model): + """ + Django model to save the history of OpenSlides. + + This is not a RESTModel. It is not cachable and can only be reached by a + special viewset. + """ + full_data = JSONField() + + class Meta: + default_permissions = () + + +class HistoryManager(models.Manager): + """ + Customized model manager for the history model. + """ + def add_elements(self, elements): + """ + Method to add elements to the history. This does not trigger autoupdate. + """ + with transaction.atomic(): + instances = [] + for element in elements: + if element['disable_history'] or element['collection_string'] == self.model.get_collection_string(): + # Do not update history for history elements itself or if history is disabled. + continue + # HistoryData is not a root rest element so there is no autoupdate and not history saving here. + data = HistoryData.objects.create(full_data=element['full_data']) + instance = self.model( + element_id=get_element_id(element['collection_string'], element['id']), + information=element['information'], + user_id=element['user_id'], + full_data=data, + ) + instance.save(skip_autoupdate=True) # Skip autoupdate and of course history saving. + instances.append(instance) + return instances + + def build_history(self): + """ + Method to add all cachables to the history. + """ + # TODO: Add lock to prevent multiple history builds at once. See #4039. + instances = None + if self.all().count() == 0: + elements = [] + all_full_data = async_to_sync(element_cache.get_all_full_data)() + for collection_string, data in all_full_data.items(): + for full_data in data: + elements.append(Element( + id=full_data['id'], + collection_string=collection_string, + full_data=full_data, + information='', + user_id=None, + disable_history=False, + )) + instances = self.add_elements(elements) + return instances + + +class History(RESTModelMixin, models.Model): + """ + Django model to save the history of OpenSlides. + + This model itself is not part of the history. This means that if you + delete a user you may lose the information of the user field here. + """ + access_permissions = HistoryAccessPermissions() + + objects = HistoryManager() + + element_id = models.CharField( + max_length=255, + ) + + now = models.DateTimeField(auto_now_add=True) + + information = models.CharField( + max_length=255, + ) + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + on_delete=models.SET_NULL) + + full_data = models.OneToOneField( + HistoryData, + on_delete=models.CASCADE, + ) + + class Meta: + default_permissions = () diff --git a/openslides/core/serializers.py b/openslides/core/serializers.py index 6693a3cc4..555056fcf 100644 --- a/openslides/core/serializers.py +++ b/openslides/core/serializers.py @@ -5,6 +5,7 @@ from .models import ( ChatMessage, ConfigStore, Countdown, + History, ProjectionDefault, Projector, ProjectorMessage, @@ -110,3 +111,14 @@ class CountdownSerializer(ModelSerializer): class Meta: model = Countdown fields = ('id', 'description', 'default_time', 'countdown_time', 'running', ) + + +class HistorySerializer(ModelSerializer): + """ + Serializer for core.models.Countdown objects. + + Does not contain full data of history object. + """ + class Meta: + model = History + fields = ('id', 'element_id', 'now', 'information', 'user', ) diff --git a/openslides/core/urls.py b/openslides/core/urls.py index b087a5bbf..cc6d6e236 100644 --- a/openslides/core/urls.py +++ b/openslides/core/urls.py @@ -11,4 +11,8 @@ urlpatterns = [ url(r'^version/$', views.VersionView.as_view(), name='core_version'), + + url(r'^history/$', + views.HistoryView.as_view(), + name='core_history'), ] diff --git a/openslides/core/views.py b/openslides/core/views.py index 383962a55..f491ed230 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -1,3 +1,4 @@ +import datetime import os import uuid from typing import Any, Dict, List @@ -16,7 +17,12 @@ from mypy_extensions import TypedDict from .. import __license__ as license, __url__ as url, __version__ as version from ..utils import views as utils_views from ..utils.arguments import arguments -from ..utils.auth import anonymous_is_enabled, has_perm +from ..utils.auth import ( + GROUP_ADMIN_PK, + anonymous_is_enabled, + has_perm, + in_some_groups, +) from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.plugins import ( get_plugin_description, @@ -26,8 +32,11 @@ from ..utils.plugins import ( get_plugin_version, ) from ..utils.rest_api import ( + GenericViewSet, + ListModelMixin, ModelViewSet, Response, + RetrieveModelMixin, ValidationError, detail_route, list_route, @@ -36,6 +45,7 @@ from .access_permissions import ( ChatMessageAccessPermissions, ConfigAccessPermissions, CountdownAccessPermissions, + HistoryAccessPermissions, ProjectorAccessPermissions, ProjectorMessageAccessPermissions, TagAccessPermissions, @@ -46,6 +56,8 @@ from .models import ( ChatMessage, ConfigStore, Countdown, + History, + HistoryData, ProjectionDefault, Projector, ProjectorMessage, @@ -716,6 +728,50 @@ class CountdownViewSet(ModelViewSet): return result +class HistoryViewSet(ListModelMixin, RetrieveModelMixin, GenericViewSet): + """ + API endpoint for History. + + There are the following views: list, retrieve, clear_history. + """ + access_permissions = HistoryAccessPermissions() + queryset = History.objects.all() + + def check_view_permissions(self): + """ + Returns True if the user has required permissions. + """ + if self.action in ('list', 'retrieve', 'clear_history'): + result = self.get_access_permissions().check_permissions(self.request.user) + else: + result = False + return result + + @list_route(methods=['post']) + def clear_history(self, request): + """ + Deletes and rebuilds the history. + """ + # Collect all history objects with their collection_string and id. + args = [] + for history_obj in History.objects.all(): + args.append((history_obj.get_collection_string(), history_obj.pk)) + + # Delete history data and history (via CASCADE) + HistoryData.objects.all().delete() + + # Trigger autoupdate. + if len(args) > 0: + inform_deleted_data(args) + + # Rebuild history. + history_instances = History.objects.build_history() + inform_changed_data(history_instances) + + # Setup response. + return Response({'detail': _('History was deleted successfully.')}) + + # Special API views class ServerTime(utils_views.APIView): @@ -755,3 +811,38 @@ class VersionView(utils_views.APIView): 'license': get_plugin_license(plugin), 'url': get_plugin_url(plugin)}) return result + + +class HistoryView(utils_views.APIView): + """ + View to retrieve the history data of OpenSlides. + + Use query paramter timestamp (UNIX timestamp) to get all elements from begin + until (including) this timestamp. + """ + http_method_names = ['get'] + + def get_context_data(self, **context): + """ + Checks if user is in admin group. If yes all history data until + (including) timestamp are added to the response data. + """ + if not in_some_groups(self.request.user.pk or 0, [GROUP_ADMIN_PK]): + self.permission_denied(self.request) + try: + timestamp = int(self.request.query_params.get('timestamp', 0)) + except (ValueError): + raise ValidationError({'detail': 'Invalid input. Timestamp should be an integer.'}) + data = [] + queryset = History.objects.select_related('full_data') + if timestamp: + queryset = queryset.filter(now__lte=datetime.datetime.fromtimestamp(timestamp)) + for instance in queryset: + data.append({ + 'full_data': instance.full_data.full_data, + 'element_id': instance.element_id, + 'timestamp': instance.now.timestamp(), + 'information': instance.information, + 'user_id': instance.user.pk if instance.user else None, + }) + return data diff --git a/openslides/motions/signals.py b/openslides/motions/signals.py index 1d6a9f85d..ffaefacd1 100644 --- a/openslides/motions/signals.py +++ b/openslides/motions/signals.py @@ -14,87 +14,103 @@ def create_builtin_workflows(sender, **kwargs): # If there is at least one workflow, then do nothing. return - workflow_1 = Workflow.objects.create(name='Simple Workflow') - state_1_1 = State.objects.create(name=ugettext_noop('submitted'), - workflow=workflow_1, - allow_create_poll=True, - allow_support=True, - allow_submitter_edit=True) - state_1_2 = State.objects.create(name=ugettext_noop('accepted'), - workflow=workflow_1, - action_word='Accept', - recommendation_label='Acceptance', - css_class='success', - merge_amendment_into_final=True) - state_1_3 = State.objects.create(name=ugettext_noop('rejected'), - workflow=workflow_1, - action_word='Reject', - recommendation_label='Rejection', - css_class='danger') - state_1_4 = State.objects.create(name=ugettext_noop('not decided'), - workflow=workflow_1, - action_word='Do not decide', - recommendation_label='No decision', - css_class='default') + workflow_1 = Workflow(name='Simple Workflow') + workflow_1.save(skip_autoupdate=True) + state_1_1 = State(name=ugettext_noop('submitted'), + workflow=workflow_1, + allow_create_poll=True, + allow_support=True, + allow_submitter_edit=True) + state_1_1.save(skip_autoupdate=True) + state_1_2 = State(name=ugettext_noop('accepted'), + workflow=workflow_1, + action_word='Accept', + recommendation_label='Acceptance', + css_class='success', + merge_amendment_into_final=True) + state_1_2.save(skip_autoupdate=True) + state_1_3 = State(name=ugettext_noop('rejected'), + workflow=workflow_1, + action_word='Reject', + recommendation_label='Rejection', + css_class='danger') + state_1_3.save(skip_autoupdate=True) + state_1_4 = State(name=ugettext_noop('not decided'), + workflow=workflow_1, + action_word='Do not decide', + recommendation_label='No decision', + css_class='default') + state_1_4.save(skip_autoupdate=True) state_1_1.next_states.add(state_1_2, state_1_3, state_1_4) workflow_1.first_state = state_1_1 - workflow_1.save() + workflow_1.save(skip_autoupdate=True) - workflow_2 = Workflow.objects.create(name='Complex Workflow') - state_2_1 = State.objects.create(name=ugettext_noop('published'), - workflow=workflow_2, - allow_support=True, - allow_submitter_edit=True, - dont_set_identifier=True) - state_2_2 = State.objects.create(name=ugettext_noop('permitted'), - workflow=workflow_2, - action_word='Permit', - recommendation_label='Permission', - allow_create_poll=True, - allow_submitter_edit=True) - state_2_3 = State.objects.create(name=ugettext_noop('accepted'), - workflow=workflow_2, - action_word='Accept', - recommendation_label='Acceptance', - css_class='success', - merge_amendment_into_final=True) - state_2_4 = State.objects.create(name=ugettext_noop('rejected'), - workflow=workflow_2, - action_word='Reject', - recommendation_label='Rejection', - css_class='danger') - state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'), - workflow=workflow_2, - action_word='Withdraw', - css_class='default') - state_2_6 = State.objects.create(name=ugettext_noop('adjourned'), - workflow=workflow_2, - action_word='Adjourn', - recommendation_label='Adjournment', - css_class='default') - state_2_7 = State.objects.create(name=ugettext_noop('not concerned'), - workflow=workflow_2, - action_word='Do not concern', - recommendation_label='No concernment', - css_class='default') - state_2_8 = State.objects.create(name=ugettext_noop('refered to committee'), - workflow=workflow_2, - action_word='Refer to committee', - recommendation_label='Referral to committee', - css_class='default') - state_2_9 = State.objects.create(name=ugettext_noop('needs review'), - workflow=workflow_2, - action_word='Needs review', - css_class='default') - state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'), - workflow=workflow_2, - action_word='Reject (not authorized)', - recommendation_label='Rejection (not authorized)', - css_class='default') + workflow_2 = Workflow(name='Complex Workflow') + workflow_2.save(skip_autoupdate=True) + state_2_1 = State(name=ugettext_noop('published'), + workflow=workflow_2, + allow_support=True, + allow_submitter_edit=True, + dont_set_identifier=True) + state_2_1.save(skip_autoupdate=True) + state_2_2 = State(name=ugettext_noop('permitted'), + workflow=workflow_2, + action_word='Permit', + recommendation_label='Permission', + allow_create_poll=True, + allow_submitter_edit=True) + state_2_2.save(skip_autoupdate=True) + state_2_3 = State(name=ugettext_noop('accepted'), + workflow=workflow_2, + action_word='Accept', + recommendation_label='Acceptance', + css_class='success', + merge_amendment_into_final=True) + state_2_3.save(skip_autoupdate=True) + state_2_4 = State(name=ugettext_noop('rejected'), + workflow=workflow_2, + action_word='Reject', + recommendation_label='Rejection', + css_class='danger') + state_2_4.save(skip_autoupdate=True) + state_2_5 = State(name=ugettext_noop('withdrawed'), + workflow=workflow_2, + action_word='Withdraw', + css_class='default') + state_2_5.save(skip_autoupdate=True) + state_2_6 = State(name=ugettext_noop('adjourned'), + workflow=workflow_2, + action_word='Adjourn', + recommendation_label='Adjournment', + css_class='default') + state_2_6.save(skip_autoupdate=True) + state_2_7 = State(name=ugettext_noop('not concerned'), + workflow=workflow_2, + action_word='Do not concern', + recommendation_label='No concernment', + css_class='default') + state_2_7.save(skip_autoupdate=True) + state_2_8 = State(name=ugettext_noop('refered to committee'), + workflow=workflow_2, + action_word='Refer to committee', + recommendation_label='Referral to committee', + css_class='default') + state_2_8.save(skip_autoupdate=True) + state_2_9 = State(name=ugettext_noop('needs review'), + workflow=workflow_2, + action_word='Needs review', + css_class='default') + state_2_9.save(skip_autoupdate=True) + state_2_10 = State(name=ugettext_noop('rejected (not authorized)'), + workflow=workflow_2, + action_word='Reject (not authorized)', + recommendation_label='Rejection (not authorized)', + css_class='default') + state_2_10.save(skip_autoupdate=True) state_2_1.next_states.add(state_2_2, state_2_5, state_2_10) state_2_2.next_states.add(state_2_3, state_2_4, state_2_5, state_2_6, state_2_7, state_2_8, state_2_9) workflow_2.first_state = state_2_1 - workflow_2.save() + workflow_2.save(skip_autoupdate=True) def get_permission_change_data(sender, permissions, **kwargs): diff --git a/openslides/motions/views.py b/openslides/motions/views.py index 400a53527..53a4cbf9f 100644 --- a/openslides/motions/views.py +++ b/openslides/motions/views.py @@ -14,7 +14,7 @@ from rest_framework import status from ..core.config import config from ..core.models import Tag from ..utils.auth import has_perm, in_some_groups -from ..utils.autoupdate import inform_changed_data +from ..utils.autoupdate import inform_changed_data, inform_deleted_data from ..utils.exceptions import OpenSlidesError from ..utils.rest_api import ( CreateModelMixin, @@ -107,7 +107,15 @@ class MotionViewSet(ModelViewSet): motion.is_submitter(request.user) and motion.state.allow_submitter_edit)): self.permission_denied(request) - return super().destroy(request, *args, **kwargs) + result = super().destroy(request, *args, **kwargs) + + # Fire autoupdate again to save information to OpenSlides history. + inform_deleted_data( + [(motion.get_collection_string(), motion.pk)], + information='Motion deleted', + user_id=request.user.pk) + + return result def create(self, request, *args, **kwargs): """ @@ -279,6 +287,12 @@ class MotionViewSet(ModelViewSet): new_users = list(updated_motion.supporters.all()) inform_changed_data(new_users) + # Fire autoupdate again to save information to OpenSlides history. + inform_changed_data( + updated_motion, + information='Motion updated', + user_id=request.user.pk) + # We do not add serializer.data to response so nobody gets unrestricted data here. return Response() @@ -630,7 +644,7 @@ class MotionViewSet(ModelViewSet): message_list=[ugettext_noop('State set to'), ' ', motion.state.name], person=request.user, skip_autoupdate=True) - inform_changed_data(motion) + inform_changed_data(motion, information='State set to {}.'.format(motion.state.name)) return Response({'detail': message}) @detail_route(methods=['put']) diff --git a/openslides/users/models.py b/openslides/users/models.py index 311364bbd..b6ca42b10 100644 --- a/openslides/users/models.py +++ b/openslides/users/models.py @@ -11,7 +11,7 @@ from django.contrib.auth.models import ( PermissionsMixin, ) from django.core import mail -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models from django.db.models import Prefetch from django.utils import timezone @@ -62,12 +62,18 @@ class UserManager(BaseUserManager): exists, resets it. The password is (re)set to 'admin'. The user becomes member of the group 'Admin'. """ - admin, created = self.get_or_create( - username='admin', - defaults={'last_name': 'Administrator'}) + created = False + try: + admin = self.get(username='admin') + except ObjectDoesNotExist: + admin = self.model( + username='admin', + last_name='Administrator', + ) + created = True admin.default_password = 'admin' admin.password = make_password(admin.default_password) - admin.save() + admin.save(skip_autoupdate=True) admin.groups.add(GROUP_ADMIN_PK) return created diff --git a/openslides/users/signals.py b/openslides/users/signals.py index e7d021a3e..6a79f9fe7 100644 --- a/openslides/users/signals.py +++ b/openslides/users/signals.py @@ -3,7 +3,6 @@ from django.contrib.auth.models import Permission from django.db.models import Q from ..utils.auth import GROUP_ADMIN_PK, GROUP_DEFAULT_PK -from ..utils.autoupdate import inform_changed_data from .models import Group, User @@ -81,11 +80,13 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['mediafiles.can_see'], permission_dict['motions.can_see'], permission_dict['users.can_see_name'], ) - group_default = Group.objects.create(pk=GROUP_DEFAULT_PK, name='Default') + group_default = Group(pk=GROUP_DEFAULT_PK, name='Default') + group_default.save(skip_autoupdate=True) group_default.permissions.add(*base_permissions) # Admin (pk 2 == GROUP_ADMIN_PK) - group_admin = Group.objects.create(pk=GROUP_ADMIN_PK, name='Admin') + group_admin = Group(pk=GROUP_ADMIN_PK, name='Admin') + group_admin.save(skip_autoupdate=True) # Delegates (pk 3) delegates_permissions = ( @@ -102,7 +103,8 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_create'], permission_dict['motions.can_support'], permission_dict['users.can_see_name'], ) - group_delegates = Group.objects.create(pk=3, name='Delegates') + group_delegates = Group(pk=3, name='Delegates') + group_delegates.save(skip_autoupdate=True) group_delegates.permissions.add(*delegates_permissions) # Staff (pk 4) @@ -132,17 +134,10 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['users.can_manage'], permission_dict['users.can_see_extra_data'], permission_dict['mediafiles.can_see_hidden'],) - group_staff = Group.objects.create(pk=4, name='Staff') + group_staff = Group(pk=4, name='Staff') + group_staff.save(skip_autoupdate=True) group_staff.permissions.add(*staff_permissions) - # Add users.can_see_name permission to staff/admin - # group to ensure proper management possibilities - # TODO: Remove this redundancy after cleanup of the permission system. - group_staff.permissions.add( - permission_dict['users.can_see_name']) - group_admin.permissions.add( - permission_dict['users.can_see_name']) - # Committees (pk 5) committees_permissions = ( permission_dict['agenda.can_see'], @@ -155,13 +150,13 @@ def create_builtin_groups_and_admin(**kwargs): permission_dict['motions.can_create'], permission_dict['motions.can_support'], permission_dict['users.can_see_name'], ) - group_committee = Group.objects.create(pk=5, name='Committees') + group_committee = Group(pk=5, name='Committees') + group_committee.save(skip_autoupdate=True) group_committee.permissions.add(*committees_permissions) # Create or reset admin user User.objects.create_or_reset_admin_user() # After each group was created, the permissions (many to many fields) where - # added to the group. So we have to update the cache by calling - # inform_changed_data(). - inform_changed_data((group_default, group_admin, group_delegates, group_staff, group_committee)) + # added to the group. But we do not have to update the cache by calling + # inform_changed_data() because the cache is updated on server start. diff --git a/openslides/users/views.py b/openslides/users/views.py index e0e8d0a81..36e1f7700 100644 --- a/openslides/users/views.py +++ b/openslides/users/views.py @@ -336,7 +336,13 @@ class GroupViewSet(ModelViewSet): for receiver, signal_collections in signal_results: for cachable in signal_collections: for full_data in all_full_data.get(cachable.get_collection_string(), {}): - elements.append(Element(id=full_data['id'], collection_string=cachable.get_collection_string(), full_data=full_data)) + elements.append(Element( + id=full_data['id'], + collection_string=cachable.get_collection_string(), + full_data=full_data, + information='', + user_id=None, + disable_history=True)) inform_changed_elements(elements) # TODO: Some permissions are deleted. diff --git a/openslides/utils/autoupdate.py b/openslides/utils/autoupdate.py index cfd359014..8514b7c1d 100644 --- a/openslides/utils/autoupdate.py +++ b/openslides/utils/autoupdate.py @@ -1,3 +1,4 @@ +import itertools import threading from typing import Any, Dict, Iterable, List, Optional, Tuple, Union @@ -15,6 +16,9 @@ Element = TypedDict( 'id': int, 'collection_string': str, 'full_data': Optional[Dict[str, Any]], + 'information': str, + 'user_id': Optional[int], + 'disable_history': bool, } ) @@ -30,12 +34,17 @@ AutoupdateFormat = TypedDict( ) -def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None: +def inform_changed_data( + instances: Union[Iterable[Model], Model], + information: str = '', + user_id: Optional[int] = None) -> None: """ Informs the autoupdate system and the caching system about the creation or update of an element. The argument instances can be one instance or an iterable over instances. + + History creation is enabled. """ root_instances = set() if not isinstance(instances, Iterable): @@ -54,7 +63,11 @@ def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None: elements[key] = Element( id=root_instance.get_rest_pk(), collection_string=root_instance.get_collection_string(), - full_data=root_instance.get_full_data()) + full_data=root_instance.get_full_data(), + information=information, + user_id=user_id, + disable_history=False, + ) bundle = autoupdate_bundle.get(threading.get_ident()) if bundle is not None: @@ -65,15 +78,27 @@ def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None: handle_changed_elements(elements.values()) -def inform_deleted_data(deleted_elements: Iterable[Tuple[str, int]]) -> None: +def inform_deleted_data( + deleted_elements: Iterable[Tuple[str, int]], + information: str = '', + user_id: Optional[int] = None) -> None: """ Informs the autoupdate system and the caching system about the deletion of elements. + + History creation is enabled. """ elements: Dict[str, Element] = {} for deleted_element in deleted_elements: key = deleted_element[0] + str(deleted_element[1]) - elements[key] = Element(id=deleted_element[1], collection_string=deleted_element[0], full_data=None) + elements[key] = Element( + id=deleted_element[1], + collection_string=deleted_element[0], + full_data=None, + information=information, + user_id=user_id, + disable_history=False, + ) bundle = autoupdate_bundle.get(threading.get_ident()) if bundle is not None: @@ -86,8 +111,11 @@ def inform_deleted_data(deleted_elements: Iterable[Tuple[str, int]]) -> None: def inform_changed_elements(changed_elements: Iterable[Element]) -> None: """ - Informs the autoupdate system about some collection elements. This is - used just to send some data to all users. + Informs the autoupdate system about some elements. This is used just to send + some data to all users. + + If you want to save history information, user id or disable history you + have to put information or flag inside the elements. """ elements = {} for changed_element in changed_elements: @@ -135,7 +163,7 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: Does nothing if elements is empty. """ - async def update_cache() -> int: + async def update_cache(elements: Iterable[Element]) -> int: """ Async helper function to update the cache. @@ -147,12 +175,12 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: cache_elements[element_id] = element['full_data'] return await element_cache.change_elements(cache_elements) - async def async_handle_collection_elements() -> None: + async def async_handle_collection_elements(elements: Iterable[Element]) -> None: """ Async helper function to update cache and send autoupdate. """ # Update cache - change_id = await update_cache() + change_id = await update_cache(elements) # Send autoupdate channel_layer = get_channel_layer() @@ -165,7 +193,36 @@ def handle_changed_elements(elements: Iterable[Element]) -> None: ) if elements: - # TODO: Save histroy here using sync code + # Save histroy here using sync code. + history_instances = save_history(elements) - # Update cache and send autoupdate - async_to_sync(async_handle_collection_elements)() + # Convert history instances to Elements. + history_elements: List[Element] = [] + for history_instance in history_instances: + history_elements.append(Element( + id=history_instance.get_rest_pk(), + collection_string=history_instance.get_collection_string(), + full_data=history_instance.get_full_data(), + information='', + user_id=None, + disable_history=True, # This does not matter because history elements can never be part of the history itself. + )) + + # Chain elements and history elements. + itertools.chain(elements, history_elements) + + # Update cache and send autoupdate using async code. + async_to_sync(async_handle_collection_elements)( + itertools.chain(elements, history_elements) + ) + + +def save_history(elements: Iterable[Element]) -> Iterable: # TODO: Try to write Iterable[History] here + """ + Thin wrapper around the call of history saving manager method. + + This is separated to patch it during tests. + """ + from ..core.models import History + + return History.objects.add_elements(elements) diff --git a/openslides/utils/projector.py b/openslides/utils/projector.py index 8975adecf..1cbc10275 100644 --- a/openslides/utils/projector.py +++ b/openslides/utils/projector.py @@ -53,8 +53,8 @@ def register_projector_elements(elements: Generator[Type[ProjectorElement], None Has to be called in the app.ready method. """ - for Element in elements: - element = Element() + for AppProjectorElement in elements: + element = AppProjectorElement() projector_elements[element.name] = element # type: ignore diff --git a/tests/integration/helpers.py b/tests/integration/helpers.py index 5083cd195..70e5903de 100644 --- a/tests/integration/helpers.py +++ b/tests/integration/helpers.py @@ -1,12 +1,10 @@ from typing import Any, Dict, List -from asgiref.sync import sync_to_async from django.db import DEFAULT_DB_ALIAS, connections from django.test.utils import CaptureQueriesContext from openslides.core.config import config from openslides.users.models import User -from openslides.utils.autoupdate import Element, inform_changed_elements class TConfig: @@ -55,17 +53,6 @@ class TUser: return elements -async def set_config(key, value): - """ - Set a config variable in the element_cache without hitting the database. - """ - collection_string = config.get_collection_string() - config_id = config.key_to_id[key] # type: ignore - full_data = {'id': config_id, 'key': key, 'value': value} - await sync_to_async(inform_changed_elements)([ - Element(id=config_id, collection_string=collection_string, full_data=full_data)]) - - def count_queries(func, *args, **kwargs) -> int: context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) with context: diff --git a/tests/integration/utils/test_consumers.py b/tests/integration/utils/test_consumers.py index 642823305..a1d41d51d 100644 --- a/tests/integration/utils/test_consumers.py +++ b/tests/integration/utils/test_consumers.py @@ -1,5 +1,6 @@ import asyncio from importlib import import_module +from unittest.mock import patch import pytest from asgiref.sync import sync_to_async @@ -13,7 +14,11 @@ from django.contrib.auth import ( from openslides.asgi import application from openslides.core.config import config -from openslides.utils.autoupdate import inform_deleted_data +from openslides.utils.autoupdate import ( + Element, + inform_changed_elements, + inform_deleted_data, +) from openslides.utils.cache import element_cache from ...unit.utils.cache_provider import ( @@ -21,7 +26,7 @@ from ...unit.utils.cache_provider import ( Collection2, get_cachable_provider, ) -from ..helpers import TConfig, TUser, set_config +from ..helpers import TConfig, TUser @pytest.fixture(autouse=True) @@ -64,15 +69,31 @@ async def communicator(get_communicator): yield get_communicator() +@pytest.fixture +async def set_config(): + """ + Set a config variable in the element_cache without hitting the database. + """ + async def _set_config(key, value): + with patch('openslides.utils.autoupdate.save_history'): + collection_string = config.get_collection_string() + config_id = config.key_to_id[key] # type: ignore + full_data = {'id': config_id, 'key': key, 'value': value} + await sync_to_async(inform_changed_elements)([ + Element(id=config_id, collection_string=collection_string, full_data=full_data, information='', user_id=None, disable_history=True)]) + + return _set_config + + @pytest.mark.asyncio -async def test_normal_connection(get_communicator): +async def test_normal_connection(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) connected, __ = await get_communicator().connect() assert connected @pytest.mark.asyncio -async def test_connection_with_change_id(get_communicator): +async def test_connection_with_change_id(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('change_id=0') await communicator.connect() @@ -93,7 +114,7 @@ async def test_connection_with_change_id(get_communicator): @pytest.mark.asyncio -async def test_connection_with_change_id_get_restricted_data_with_restricted_data_cache(get_communicator): +async def test_connection_with_change_id_get_restricted_data_with_restricted_data_cache(get_communicator, set_config): """ Test, that the returned data is the restricted_data when restricted_data_cache is activated """ @@ -116,7 +137,7 @@ async def test_connection_with_change_id_get_restricted_data_with_restricted_dat @pytest.mark.asyncio -async def test_connection_with_invalid_change_id(get_communicator): +async def test_connection_with_invalid_change_id(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('change_id=invalid') connected, __ = await communicator.connect() @@ -125,7 +146,7 @@ async def test_connection_with_invalid_change_id(get_communicator): @pytest.mark.asyncio -async def test_connection_with_to_big_change_id(get_communicator): +async def test_connection_with_to_big_change_id(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('change_id=100') @@ -136,7 +157,7 @@ async def test_connection_with_to_big_change_id(get_communicator): @pytest.mark.asyncio -async def test_changed_data_autoupdate_off(communicator): +async def test_changed_data_autoupdate_off(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -146,7 +167,7 @@ async def test_changed_data_autoupdate_off(communicator): @pytest.mark.asyncio -async def test_changed_data_autoupdate_on(get_communicator): +async def test_changed_data_autoupdate_on(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('autoupdate=on') await communicator.connect() @@ -191,13 +212,14 @@ async def test_with_user(): @pytest.mark.asyncio -async def test_receive_deleted_data(get_communicator): +async def test_receive_deleted_data(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('autoupdate=on') await communicator.connect() # Delete test element - await sync_to_async(inform_deleted_data)([(Collection1().get_collection_string(), 1)]) + with patch('openslides.utils.autoupdate.save_history'): + await sync_to_async(inform_deleted_data)([(Collection1().get_collection_string(), 1)]) response = await communicator.receive_json_from() type = response.get('type') @@ -207,7 +229,7 @@ async def test_receive_deleted_data(get_communicator): @pytest.mark.asyncio -async def test_send_notify(communicator): +async def test_send_notify(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -223,7 +245,7 @@ async def test_send_notify(communicator): @pytest.mark.asyncio -async def test_invalid_websocket_message_type(communicator): +async def test_invalid_websocket_message_type(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -234,7 +256,7 @@ async def test_invalid_websocket_message_type(communicator): @pytest.mark.asyncio -async def test_invalid_websocket_message_no_id(communicator): +async def test_invalid_websocket_message_no_id(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -245,7 +267,7 @@ async def test_invalid_websocket_message_no_id(communicator): @pytest.mark.asyncio -async def test_send_unknown_type(communicator): +async def test_send_unknown_type(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -257,7 +279,7 @@ async def test_send_unknown_type(communicator): @pytest.mark.asyncio -async def test_request_constants(communicator, settings): +async def test_request_constants(communicator, settings, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -270,7 +292,7 @@ async def test_request_constants(communicator, settings): @pytest.mark.asyncio -async def test_send_get_elements(communicator): +async def test_send_get_elements(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -291,7 +313,7 @@ async def test_send_get_elements(communicator): @pytest.mark.asyncio -async def test_send_get_elements_to_big_change_id(communicator): +async def test_send_get_elements_to_big_change_id(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -304,7 +326,7 @@ async def test_send_get_elements_to_big_change_id(communicator): @pytest.mark.asyncio -async def test_send_get_elements_to_small_change_id(communicator): +async def test_send_get_elements_to_small_change_id(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -318,7 +340,7 @@ async def test_send_get_elements_to_small_change_id(communicator): @pytest.mark.asyncio -async def test_send_connect_twice_with_clear_change_id_cache(communicator): +async def test_send_connect_twice_with_clear_change_id_cache(communicator, set_config): """ Test, that a second request with change_id+1 from the first request, returns an error. @@ -338,7 +360,7 @@ async def test_send_connect_twice_with_clear_change_id_cache(communicator): @pytest.mark.asyncio -async def test_send_connect_twice_with_clear_change_id_cache_same_change_id_then_first_request(communicator): +async def test_send_connect_twice_with_clear_change_id_cache_same_change_id_then_first_request(communicator, set_config): """ Test, that a second request with the change_id from the first request, returns all data. @@ -360,7 +382,7 @@ async def test_send_connect_twice_with_clear_change_id_cache_same_change_id_then @pytest.mark.asyncio -async def test_request_changed_elements_no_douple_elements(communicator): +async def test_request_changed_elements_no_douple_elements(communicator, set_config): """ Test, that when an elements is changed twice, it is only returned onces when ask a range of change ids. @@ -386,7 +408,7 @@ async def test_request_changed_elements_no_douple_elements(communicator): @pytest.mark.asyncio -async def test_send_invalid_get_elements(communicator): +async def test_send_invalid_get_elements(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -399,7 +421,7 @@ async def test_send_invalid_get_elements(communicator): @pytest.mark.asyncio -async def test_turn_on_autoupdate(communicator): +async def test_turn_on_autoupdate(communicator, set_config): await set_config('general_system_enable_anonymous', True) await communicator.connect() @@ -418,7 +440,7 @@ async def test_turn_on_autoupdate(communicator): @pytest.mark.asyncio -async def test_turn_off_autoupdate(get_communicator): +async def test_turn_off_autoupdate(get_communicator, set_config): await set_config('general_system_enable_anonymous', True) communicator = get_communicator('autoupdate=on') await communicator.connect() diff --git a/tests/unit/motions/test_views.py b/tests/unit/motions/test_views.py index e50734d7d..f42638c82 100644 --- a/tests/unit/motions/test_views.py +++ b/tests/unit/motions/test_views.py @@ -21,7 +21,8 @@ class MotionViewSetUpdate(TestCase): @patch('openslides.motions.views.has_perm') @patch('openslides.motions.views.config') def test_simple_update(self, mock_config, mock_has_perm, mock_icd): - self.request.user = 1 + self.request.user = MagicMock() + self.request.user.pk = 1 self.request.data.get.return_value = MagicMock() mock_has_perm.return_value = True diff --git a/tests/unit/users/test_models.py b/tests/unit/users/test_models.py index 5f20a1872..59ab834df 100644 --- a/tests/unit/users/test_models.py +++ b/tests/unit/users/test_models.py @@ -1,6 +1,8 @@ from unittest import TestCase from unittest.mock import MagicMock, call, patch +from django.core.exceptions import ObjectDoesNotExist + from openslides.users.models import UserManager @@ -127,7 +129,7 @@ class UserManagerCreateOrResetAdminUser(TestCase): """ admin_user = MagicMock() manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, False)) + manager.get = MagicMock(return_value=(admin_user)) manager.create_or_reset_admin_user() @@ -139,7 +141,7 @@ class UserManagerCreateOrResetAdminUser(TestCase): """ admin_user = MagicMock() manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, False)) + manager.get = MagicMock(return_value=(admin_user)) staff_group = MagicMock(name="Staff") mock_group.objects.get_or_create = MagicMock(return_value=(staff_group, True)) @@ -150,16 +152,15 @@ class UserManagerCreateOrResetAdminUser(TestCase): self.assertEqual( admin_user.default_password, 'admin') - admin_user.save.assert_called_once_with() + admin_user.save.assert_called_once_with(skip_autoupdate=True) @patch('openslides.users.models.User') def test_return_value(self, mock_user, mock_group, mock_permission): """ Tests that the method returns True when a user is created. """ - admin_user = MagicMock() manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, True)) + manager.get = MagicMock(side_effect=ObjectDoesNotExist) manager.model = mock_user staff_group = MagicMock(name="Staff") @@ -179,7 +180,7 @@ class UserManagerCreateOrResetAdminUser(TestCase): """ admin_user = MagicMock(username='admin', last_name='Administrator') manager = UserManager() - manager.get_or_create = MagicMock(return_value=(admin_user, True)) + manager.get = MagicMock(side_effect=ObjectDoesNotExist) manager.model = mock_user staff_group = MagicMock(name="Staff")