Merge pull request #3977 from normanjaeckel/HistoryModel

OpenSlides history mode
This commit is contained in:
Sean 2018-11-30 12:51:09 +01:00 committed by GitHub
commit c416f7a1be
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
58 changed files with 1541 additions and 263 deletions

View File

@ -20,6 +20,7 @@ Core:
- Add a change-id system to get only new elements [#3938]. - Add a change-id system to get only new elements [#3938].
- Switch from Yarn back to npm [#3964]. - Switch from Yarn back to npm [#3964].
- Added password reset link (password reset via email) [#3914]. - Added password reset link (password reset via email) [#3914].
- Added global history mode [#3977].
Agenda: Agenda:
- Added viewpoint to assign multiple items to a new parent item [#4037]. - Added viewpoint to assign multiple items to a new parent item [#4037].

View File

@ -0,0 +1,30 @@
type QueryParamValue = string | number | boolean;
/**
* A key value mapping for params, that should be appended to the url on a new connection.
*/
export interface QueryParams {
[key: string]: QueryParamValue;
}
/**
* Formats query params for the url.
*
* @param queryParams
* @returns the formatted query params as string
*/
export function formatQueryParams(queryParams: QueryParams = {}): string {
let params = '';
const keys: string[] = Object.keys(queryParams);
if (keys.length > 0) {
params =
'?' +
keys
.map(key => {
return key + '=' + queryParams[key].toString();
})
.join('&');
}
return params;
}

View File

@ -11,6 +11,7 @@ import { AssignmentsAppConfig } from '../../site/assignments/assignments.config'
import { UsersAppConfig } from '../../site/users/users.config'; import { UsersAppConfig } from '../../site/users/users.config';
import { TagAppConfig } from '../../site/tags/tag.config'; import { TagAppConfig } from '../../site/tags/tag.config';
import { MainMenuService } from './main-menu.service'; import { MainMenuService } from './main-menu.service';
import { HistoryAppConfig } from 'app/site/history/history.config';
/** /**
* A list of all app configurations of all delivered apps. * A list of all app configurations of all delivered apps.
@ -23,7 +24,8 @@ const appConfigs: AppConfig[] = [
MotionsAppConfig, MotionsAppConfig,
MediafileAppConfig, MediafileAppConfig,
TagAppConfig, TagAppConfig,
UsersAppConfig UsersAppConfig,
HistoryAppConfig
]; ];
/** /**

View File

@ -330,13 +330,26 @@ export class DataStoreService {
/** /**
* Resets the DataStore and set the given models as the new content. * Resets the DataStore and set the given models as the new content.
* @param models A list of models to set the DataStore to. * @param models A list of models to set the DataStore to.
* @param newMaxChangeId Optional. If given, the max change id will be updated. * @param newMaxChangeId Optional. If given, the max change id will be updated
* and the store flushed to the storage
*/ */
public async set(models: BaseModel[], newMaxChangeId?: number): Promise<void> { public async set(models?: BaseModel[], newMaxChangeId?: number): Promise<void> {
const modelStoreReference = this.modelStore;
this.modelStore = {}; this.modelStore = {};
this.jsonStore = {}; this.jsonStore = {};
// 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); await this.add(models, newMaxChangeId);
} }
}
/** /**
* Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger * Updates the cache by inserting the serialized DataStore. Also changes the chageId, if it's larger

View File

@ -1,6 +1,8 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http'; import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { formatQueryParams, QueryParams } from '../query-params';
import { OpenSlidesStatusService } from './openslides-status.service';
/** /**
* Enum for different HTTPMethods * Enum for different HTTPMethods
@ -32,34 +34,45 @@ export class HttpService {
* *
* @param http The HTTP Client * @param http The HTTP Client
* @param translate * @param translate
* @param timeTravel requests are only allowed if history mode is disabled
*/ */
public constructor(private http: HttpClient, private translate: TranslateService) { public constructor(
this.defaultHeaders = new HttpHeaders().set('Content-Type', 'application/json') 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. * 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 method the required HTTP method (i.e get, post, put)
* @param data optional, if sending a data body is required * @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 * @param customHeader optional custom HTTP header of required
* @returns a promise containing a generic * @returns a promise containing a generic
*/ */
private async send<T>(url: string, method: HTTPMethod, data?: any, customHeader?: HttpHeaders): Promise<T> { private async send<T>(path: string, method: HTTPMethod, data?: any, queryParams?: QueryParams, customHeader?: HttpHeaders): Promise<T> {
if (!url.endsWith('/')) { // end early, if we are in history mode
url += '/'; 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 = { const options = {
body: data, body: data,
headers: customHeader ? customHeader : this.defaultHeaders headers: customHeader ? customHeader : this.defaultHeaders
}; };
try { try {
const response = await this.http.request<T>(method, url, options).toPromise(); return await this.http.request<T>(method, url, options).toPromise();
return response;
} catch (e) { } catch (e) {
throw this.handleError(e); throw this.handleError(e);
} }
@ -73,6 +86,12 @@ export class HttpService {
*/ */
private handleError(e: any): string { private handleError(e: any): string {
let error = this.translate.instant('Error') + ': '; 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 the error is no HttpErrorResponse, it's not clear what is wrong.
if (!(e instanceof HttpErrorResponse)) { if (!(e instanceof HttpErrorResponse)) {
console.error('Unknown error thrown by the http client: ', e); 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 * Executes a get on a path with a certain object
* @param url The url to send the request to. * @param path The path to send the request to.
* @param data An optional payload for the request. * @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 * @param header optional HTTP header if required
* @returns A promise holding a generic * @returns A promise holding a generic
*/ */
public async get<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> { public async get<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.GET, data, header); return await this.send<T>(path, HTTPMethod.GET, data, queryParams, header);
} }
/** /**
* Exectures a post on a url with a certain object * Executes a post on a path with a certain object
* @param url The url to send the request to. * @param path The path to send the request to.
* @param data An optional payload for the request. * @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 * @param header optional HTTP header if required
* @returns A promise holding a generic * @returns A promise holding a generic
*/ */
public async post<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> { public async post<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.POST, data, header); return await this.send<T>(path, HTTPMethod.POST, data, queryParams, header);
} }
/** /**
* Exectures a put on a url with a certain object * Executes a put on a path with a certain object
* @param url The url to send the request to. * @param path The path to send the request to.
* @param data The payload for the request. * @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 * @param header optional HTTP header if required
* @returns A promise holding a generic * @returns A promise holding a generic
*/ */
public async patch<T>(url: string, data: any, header?: HttpHeaders): Promise<T> { public async patch<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.PATCH, data, header); return await this.send<T>(path, HTTPMethod.PATCH, data, queryParams, header);
} }
/** /**
* Exectures a put on a url with a certain object * Executes a put on a path with a certain object
* @param url The url to send the request to. * @param path The path to send the request to.
* @param data: The payload for the request. * @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 * @param header optional HTTP header if required
* @returns A promise holding a generic * @returns A promise holding a generic
*/ */
public async put<T>(url: string, data: any, header?: HttpHeaders): Promise<T> { public async put<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.PUT, data, header); return await this.send<T>(path, HTTPMethod.PUT, data, queryParams, header);
} }
/** /**
* Makes a delete request. * Makes a delete request.
* @param url The url to send the request to. * @param url The path to send the request to.
* @param data An optional data to send in the requestbody. * @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 * @param header optional HTTP header if required
* @returns A promise holding a generic * @returns A promise holding a generic
*/ */
public async delete<T>(url: string, data?: any, header?: HttpHeaders): Promise<T> { public async delete<T>(path: string, data?: any, queryParams?: QueryParams, header?: HttpHeaders): Promise<T> {
return await this.send<T>(url, HTTPMethod.DELETE, data, header); return await this.send<T>(path, HTTPMethod.DELETE, data, queryParams, header);
} }
} }

View File

@ -0,0 +1,17 @@
import { TestBed, inject } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { OpenSlidesStatusService } from './openslides-status.service';
describe('OpenSlidesStatusService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [OpenSlidesStatusService]
});
});
it('should be created', inject([OpenSlidesStatusService], (service: OpenSlidesStatusService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,42 @@
import { Injectable } from '@angular/core';
/**
* Holds information about OpenSlides. This is not included into other services to
* avoid circular dependencies.
*/
@Injectable({
providedIn: 'root'
})
export class OpenSlidesStatusService {
/**
* Saves, if OpenSlides is in the history mode.
*/
private historyMode = false;
/**
* Returns, if OpenSlides is in the history mode.
*/
public get isInHistoryMode(): boolean {
return this.historyMode;
}
/**
* Ctor, does nothing.
*/
public constructor() {}
/**
* Enters the histroy mode
*/
public enterHistoryMode(): void {
this.historyMode = true;
}
/**
* Leaves the histroy mode
*/
public leaveHistroyMode(): void {
this.historyMode = false;
}
}

View File

@ -1,5 +1,6 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage'; 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 * 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. * Constructor to create the StorageService. Needs the localStorage service.
* @param localStorage * @param localStorage
*/ */
public constructor(private localStorage: LocalStorage) {} public constructor(private localStorage: LocalStorage, private OSStatus: OpenSlidesStatusService) {}
/** /**
* Sets the item into the store asynchronously. * Sets the item into the store asynchronously.
@ -21,6 +22,7 @@ export class StorageService {
* @param item * @param item
*/ */
public async set(key: string, item: any): Promise<void> { public async set(key: string, item: any): Promise<void> {
this.assertNotHistroyMode();
if (item === null || item === undefined) { if (item === null || item === undefined) {
await this.remove(key); // You cannot do a setItem with null or undefined... await this.remove(key); // You cannot do a setItem with null or undefined...
} else { } else {
@ -48,6 +50,7 @@ export class StorageService {
* @param key The key to remove the value from * @param key The key to remove the value from
*/ */
public async remove(key: string): Promise<void> { public async remove(key: string): Promise<void> {
this.assertNotHistroyMode();
if (!(await this.localStorage.removeItem(key).toPromise())) { if (!(await this.localStorage.removeItem(key).toPromise())) {
throw new Error('Could not delete the item.'); throw new Error('Could not delete the item.');
} }
@ -57,9 +60,18 @@ export class StorageService {
* Clear the whole cache * Clear the whole cache
*/ */
public async clear(): Promise<void> { public async clear(): Promise<void> {
console.log('clear storage'); this.assertNotHistroyMode();
if (!(await this.localStorage.clear().toPromise())) { if (!(await this.localStorage.clear().toPromise())) {
throw new Error('Could not clear the storage.'); throw new Error('Could not clear the storage.');
} }
} }
/**
* Throws an error, if we are in history mode.
*/
private assertNotHistroyMode(): void {
if (this.OSStatus.isInHistoryMode) {
throw new Error('You cannot use the storageService in histroy mode.');
}
}
} }

View File

@ -0,0 +1,16 @@
import { TestBed } from '@angular/core/testing';
import { TimeTravelService } from './time-travel.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('TimeTravelService', () => {
beforeEach(() => TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [TimeTravelService]
}));
it('should be created', () => {
const service: TimeTravelService = TestBed.get(TimeTravelService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,119 @@
import { Injectable } from '@angular/core';
import { environment } from 'environments/environment';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
import { History } from 'app/shared/models/core/history';
import { DataStoreService } from './data-store.service';
import { WebsocketService } from './websocket.service';
import { BaseModel } from 'app/shared/models/base/base-model';
import { OpenSlidesStatusService } from './openslides-status.service';
import { OpenSlidesService } from './openslides.service';
import { HttpService } from './http.service';
/**
* Interface for full history data objects.
* The are not too different from the history-objects,
* but contain full-data and a timestamp in contrast to a date
*/
interface HistoryData {
element_id: string;
full_data: BaseModel;
information: string;
timestamp: number;
user_id: number;
}
/**
* Service to enable browsing OpenSlides in a previous version.
*
* This should stop auto updates, save the current ChangeID and overwrite the DataStore with old Values
* from the servers History.
*
* Restoring is nor possible yet. Simply reload
*/
@Injectable({
providedIn: 'root'
})
export class TimeTravelService {
/**
* Constructs the time travel service
*
* @param httpService To fetch the history data
* @param webSocketService to disable websocket connection
* @param modelMapperService to cast history objects into models
* @param DS to overwrite the dataStore
* @param OSStatus Sets the history status
* @param OpenSlides For restarting OpenSlide when exiting the history mode
*/
public constructor(
private httpService: HttpService,
private webSocketService: WebsocketService,
private modelMapperService: CollectionStringModelMapperService,
private DS: DataStoreService,
private OSStatus: OpenSlidesStatusService,
private OpenSlides: OpenSlidesService
) { }
/**
* Main entry point to set OpenSlides to another history point.
*
* @param history the desired point in the history of OpenSlides
*/
public async loadHistoryPoint(history: History): Promise<void> {
await this.stopTime();
const fullDataHistory: HistoryData[] = await this.getHistoryData(history);
for (const historyObject of fullDataHistory) {
let collectionString: string;
let id: string;
[collectionString, id] = historyObject.element_id.split(':')
if (historyObject.full_data) {
const targetClass = this.modelMapperService.getModelConstructor(collectionString);
await this.DS.add([new targetClass(historyObject.full_data)])
} else {
await this.DS.remove(collectionString, [+id]);
}
}
}
/**
* Leaves the history mode. Just restart OpenSlides:
* The active user is chacked, a new WS connection established and
* all missed autoupdates are requested.
*/
public async resumeTime(): Promise<void> {
await this.DS.set();
await this.OpenSlides.reboot();
this.OSStatus.leaveHistroyMode();
}
/**
* Read the history on a given time
*
* @param date the Date object
* @returns the full history on the given date
*/
private async getHistoryData(history: History): Promise<HistoryData[]> {
const historyUrl = '/core/history/'
const queryParams = { timestamp: Math.ceil(+history.unixtime) };
return this.httpService.get<HistoryData[]>(environment.urlPrefix + historyUrl, null, queryParams);
}
/**
* Clears the DataStore and stops the WebSocket connection
*/
private async stopTime(): Promise<void> {
this.webSocketService.close();
await this.cleanDataStore();
this.OSStatus.enterHistoryMode();
}
/**
* Clean the DataStore to inject old Data.
* Remove everything "but" the history.
*/
private async cleanDataStore(): Promise<void> {
const historyArchive = this.DS.getAll(History);
await this.DS.set(historyArchive);
}
}

View File

@ -2,15 +2,7 @@ import { Injectable, NgZone, EventEmitter } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material'; import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material';
import { TranslateService } from '@ngx-translate/core'; import { TranslateService } from '@ngx-translate/core';
import { formatQueryParams, QueryParams } from '../query-params';
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;
}
/** /**
* The generic message format in which messages are send and recieved by the server. * The generic message format in which messages are send and recieved by the server.
@ -116,7 +108,7 @@ export class WebsocketService {
// Create the websocket // Create the websocket
let socketPath = location.protocol === 'https:' ? 'wss://' : 'ws://'; let socketPath = location.protocol === 'https:' ? 'wss://' : 'ws://';
socketPath += window.location.hostname + ':' + window.location.port + '/ws/'; socketPath += window.location.hostname + ':' + window.location.port + '/ws/';
socketPath += this.formatQueryParams(queryParams); socketPath += formatQueryParams(queryParams);
console.log('connect to', socketPath); console.log('connect to', socketPath);
this.websocket = new WebSocket(socketPath); this.websocket = new WebSocket(socketPath);
@ -225,24 +217,4 @@ export class WebsocketService {
} }
this.websocket.send(JSON.stringify(message)); this.websocket.send(JSON.stringify(message));
} }
/**
* Formats query params for the url.
* @param queryParams
* @returns the formatted query params as string
*/
private formatQueryParams(queryParams: QueryParams = {}): string {
let params = '';
const keys: string[] = Object.keys(queryParams);
if (keys.length > 0) {
params =
'?' +
keys
.map(key => {
return key + '=' + queryParams[key].toString();
})
.join('&');
}
return params;
}
} }

View File

@ -0,0 +1,39 @@
import { BaseModel } from '../base/base-model';
/**
* Representation of a history object.
*
* @ignore
*/
export class History extends BaseModel {
public id: number;
public element_id: string;
public now: string;
public information: string;
public user_id: number;
/**
* return a date our of the given timestamp
*
* @returns a Data object
*/
public get date(): Date {
return new Date(this.now);
}
/**
* Converts the timestamp to unix time
*/
public get unixtime(): number {
return Date.parse(this.now) / 1000;
}
public constructor(input?: any) {
super('core/history', input);
}
public getTitle(): string {
return this.element_id;
}
}

View File

@ -1,9 +1,10 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { AssignmentRepositoryService } from './assignment-repository.service'; import { AssignmentRepositoryService } from './assignment-repository.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('AssignmentRepositoryService', () => { describe('AssignmentRepositoryService', () => {
beforeEach(() => TestBed.configureTestingModule({})); beforeEach(() => TestBed.configureTestingModule({ imports: [E2EImportsModule] }));
it('should be created', () => { it('should be created', () => {
const service: AssignmentRepositoryService = TestBed.get(AssignmentRepositoryService); const service: AssignmentRepositoryService = TestBed.get(AssignmentRepositoryService);

View File

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

View File

@ -1,5 +1,6 @@
import { OpenSlidesComponent } from '../../openslides.component';
import { BehaviorSubject, Observable } from 'rxjs'; import { BehaviorSubject, Observable } from 'rxjs';
import { OpenSlidesComponent } from '../../openslides.component';
import { BaseViewModel } from './base-view-model'; import { BaseViewModel } from './base-view-model';
import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model';
import { CollectionStringModelMapperService } from '../../core/services/collectionStringModelMapper.service'; import { CollectionStringModelMapperService } from '../../core/services/collectionStringModelMapper.service';
@ -24,7 +25,10 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode
protected readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]); protected readonly viewModelListSubject: BehaviorSubject<V[]> = new BehaviorSubject<V[]>([]);
/** /**
* Construction routine for the base repository
* *
* @param DS: The DataStore
* @param collectionStringModelMapperService Mapping strings to their corresponding classes
* @param baseModelCtor The model constructor of which this repository is about. * @param baseModelCtor The model constructor of which this repository is about.
* @param depsModelCtors A list of constructors that are used in the view model. * @param depsModelCtors A list of constructors that are used in the view model.
* If one of those changes, the view models will be updated. * If one of those changes, the view models will be updated.
@ -33,7 +37,7 @@ export abstract class BaseRepository<V extends BaseViewModel, M extends BaseMode
protected DS: DataStoreService, protected DS: DataStoreService,
protected collectionStringModelMapperService: CollectionStringModelMapperService, protected collectionStringModelMapperService: CollectionStringModelMapperService,
protected baseModelCtor: ModelConstructor<M>, protected baseModelCtor: ModelConstructor<M>,
protected depsModelCtors?: ModelConstructor<BaseModel>[] protected depsModelCtors?: ModelConstructor<BaseModel>[],
) { ) {
super(); super();
this.setup(); this.setup();

View File

@ -1,8 +1,10 @@
import { BaseComponent } from '../../base.component';
import { Title } from '@angular/platform-browser';
import { TranslateService } from '@ngx-translate/core';
import { MatSnackBar, MatSnackBarRef, SimpleSnackBar } from '@angular/material';
import { OnDestroy } from '@angular/core'; import { 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 * A base class for all views. Implements a generic error handling by raising a snack bar

View File

@ -86,6 +86,11 @@ export class ConfigRepositoryService extends BaseRepository<ViewConfig, Config>
/** /**
* Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure. * 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( public constructor(
DS: DataStoreService, DS: DataStoreService,

View File

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

View File

@ -0,0 +1,26 @@
.os-listview-table {
/** Time */
.mat-column-time {
flex: 1 0 50px;
}
/** Element */
.mat-column-element {
flex: 3 0 50px;
}
/** Info */
.mat-column-info {
flex: 1 0 50px;
}
/** User */
.mat-column-user {
flex: 1 0 50px;
}
}
.no-info {
font-style: italic;
color: slategray; // TODO: Colors per theme
}

View File

@ -0,0 +1,26 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { E2EImportsModule } from 'e2e-imports.module';
import { HistoryListComponent } from './history-list.component';
describe('HistoryListComponent', () => {
let component: HistoryListComponent;
let fixture: ComponentFixture<HistoryListComponent>;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
declarations: [HistoryListComponent]
}).compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(HistoryListComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -0,0 +1,112 @@
import { Component, OnInit } from '@angular/core';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material';
import { Subject } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { ListViewBaseComponent } from 'app/site/base/list-view-base';
import { HistoryRepositoryService } from '../../services/history-repository.service';
import { ViewHistory } from '../../models/view-history';
/**
* A list view for the history.
*
* Should display all changes that have been made in OpenSlides.
*/
@Component({
selector: 'os-history-list',
templateUrl: './history-list.component.html',
styleUrls: ['./history-list.component.scss']
})
export class HistoryListComponent extends ListViewBaseComponent<ViewHistory> implements OnInit {
/**
* Subject determine when the custom timestamp subject changes
*/
public customTimestampChanged: Subject<number> = new Subject<number>();
/**
* Constructor for the history list component
*
* @param titleService Setting the title
* @param translate Handle translations
* @param matSnackBar Showing errors and messages
* @param repo The history repository
*/
public constructor(
titleService: Title,
translate: TranslateService,
matSnackBar: MatSnackBar,
private repo: HistoryRepositoryService
) {
super(titleService, translate, matSnackBar);
}
/**
* Init function for the history list.
*/
public ngOnInit(): void {
super.setTitle('History');
this.initTable();
this.repo.getViewModelListObservable().subscribe(history => {
this.sortAndPublish(history);
});
}
/**
* Sorts the given ViewHistory array and sets it in the table data source
*
* @param unsortedHistoryList
*/
private sortAndPublish(unsortedHistoryList: ViewHistory[]): void {
const sortedList = unsortedHistoryList.map(history => history);
sortedList.sort((a, b) => b.history.unixtime - a.history.unixtime);
this.dataSource.data = sortedList;
}
/**
* Returns the row definition for the table
*
* @returns an array of strings that contains the required row definition
*/
public getRowDef(): string[] {
return ['time', 'element', 'info', 'user'];
}
/**
* Tries get the title of the BaseModel element corresponding to
* a history object.
*
* @param history the history
* @returns the title of an old element or null if it could not be found
*/
public getElementInfo(history: ViewHistory): string {
const oldElementTitle = this.repo.getOldModelInfo(history.getCollectionString(), history.getModelID());
if (oldElementTitle) {
return oldElementTitle;
} else {
return null;
}
}
/**
* Click handler for rows in the history table.
* Serves as an entry point for the time travel routine
*
* @param history Represents the selected element
*/
public onClickRow(history: ViewHistory): void {
this.repo.browseHistory(history).then(() => {
this.raiseError(`Temporarily reset OpenSlides to the state from ${history.getLocaleString('DE-de')}`);
});
}
/**
* Handler for the delete all button
*/
public onDeleteAllButton(): void {
this.repo.delete();
}
}

View File

@ -0,0 +1,17 @@
import { NgModule } from '@angular/core';
import { Routes, RouterModule } from '@angular/router';
import { HistoryListComponent } from './components/history-list/history-list.component';
/**
* Define the routes for the history module
*/
const routes: Routes = [{ path: '', component: HistoryListComponent }];
/**
* Define the routing component and setup the routes
*/
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HistoryRoutingModule {}

View File

@ -0,0 +1,20 @@
import { AppConfig } from '../base/app-config';
import { History } from 'app/shared/models/core/history';
/**
* Config object for history.
* Hooks into the navigation.
*/
export const HistoryAppConfig: AppConfig = {
name: 'history',
models: [{ collectionString: 'core/history', model: History }],
mainMenuEntries: [
{
route: '/history',
displayName: 'History',
icon: 'history',
weight: 1200,
permission: 'core.view_history'
}
]
};

View File

@ -0,0 +1,16 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HistoryRoutingModule } from './history-routing.module';
import { SharedModule } from '../../shared/shared.module';
import { HistoryListComponent } from './components/history-list/history-list.component';
/**
* App module for the history feature.
* Declares the used components.
*/
@NgModule({
imports: [CommonModule, HistoryRoutingModule, SharedModule],
declarations: [HistoryListComponent]
})
export class HistoryModule {}

View File

@ -0,0 +1,132 @@
import { BaseViewModel } from 'app/site/base/base-view-model';
import { History } from 'app/shared/models/core/history';
import { User } from 'app/shared/models/users/user';
import { BaseModel } from 'app/shared/models/base/base-model';
/**
* View model for history objects
*/
export class ViewHistory extends BaseViewModel {
/**
* Private BaseModel of the history
*/
private _history: History;
/**
* Real representation of the user who altered the history.
* Determined from `History.user_id`
*/
private _user: User;
/**
* Read the history property
*/
public get history(): History {
return this._history ? this._history : null;
}
/**
* Read the user property
*/
public get user(): User {
return this._user ? this._user : null;
}
/**
* Get the ID of the history object
* Required by BaseViewModel
*
* @returns the ID as number
*/
public get id(): number {
return this.history ? this.history.id : null;
}
/**
* Get the elementIs of the history object
*
* @returns the element ID as String
*/
public get element_id(): string {
return this.history ? this.history.element_id : null;
}
/**
* Get the information about the history
*
* @returns a string with the information to the history object
*/
public get information(): string {
return this.history ? this.history.information : null;
}
/**
* Get the time of the history as number
*
* @returns the unix timestamp as number
*/
public get now(): string {
return this.history ? this.history.now : null;
}
/**
* Construction of a ViewHistory
*
* @param history the real history BaseModel
* @param user the real user BaseModel
*/
public constructor(history?: History, user?: User) {
super();
this._history = history;
this._user = user;
}
/**
* Converts the date (this.now) to a time and date string.
*
* @param locale locale indicator, i.e 'de-DE'
* @returns a human readable kind of time and date representation
*/
public getLocaleString(locale: string): string {
return this.history.date ? this.history.date.toLocaleString(locale) : null;
}
/**
* Converts elementID into collection string
* @returns the CollectionString to the model
*/
public getCollectionString(): string {
return this.element_id.split(":")[0]
}
/**
* Extract the models ID from the elementID
* @returns a model id
*/
public getModelID(): number {
return +this.element_id.split(":")[1]
}
/**
* Get the history objects title
* Required by BaseViewModel
*
* @returns history.getTitle which returns the element_id
*/
public getTitle(): string {
return this.history.getTitle();
}
/**
* Updates the history object with new values
*
* @param update potentially the new values for history or it's components.
*/
public updateValues(update: BaseModel): void {
if (update instanceof History && this.history.id === update.id) {
this._history = update;
} else if (this.history && update instanceof User && this.history.user_id === update.id) {
this._user = update;
}
}
}

View File

@ -0,0 +1,18 @@
import { TestBed } from '@angular/core/testing';
import { HistoryRepositoryService } from './history-repository.service';
import { E2EImportsModule } from 'e2e-imports.module';
describe('HistoryRepositoryService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [E2EImportsModule],
providers: [HistoryRepositoryService]
});
});
it('should be created', () => {
const service = TestBed.get(HistoryRepositoryService);
expect(service).toBeTruthy();
});
});

View File

@ -0,0 +1,102 @@
import { Injectable } from '@angular/core';
import { CollectionStringModelMapperService } from 'app/core/services/collectionStringModelMapper.service';
import { DataStoreService } from 'app/core/services/data-store.service';
import { BaseRepository } from 'app/site/base/base-repository';
import { History } from 'app/shared/models/core/history';
import { User } from 'app/shared/models/users/user';
import { Identifiable } from 'app/shared/models/base/identifiable';
import { HttpService } from 'app/core/services/http.service';
import { ViewHistory } from '../models/view-history';
import { TimeTravelService } from 'app/core/services/time-travel.service';
import { BaseModel } from 'app/shared/models/base/base-model';
/**
* Repository for the history.
*
* Gets new history objects/entries and provides them for the view.
*/
@Injectable({
providedIn: 'root'
})
export class HistoryRepositoryService extends BaseRepository<ViewHistory, History> {
/**
* Constructs the history repository
*
* @param DS The DataStore
* @param mapperService mapps the models to the collection string
* @param httpService OpenSlides own HTTP service
* @param timeTravel To change the time
*/
public constructor(
DS: DataStoreService,
mapperService: CollectionStringModelMapperService,
private httpService: HttpService,
private timeTravel: TimeTravelService
) {
super(DS, mapperService, History, [User]);
}
/**
* Clients usually do not need to create a history object themselves
* @ignore
*/
public async create(): Promise<Identifiable> {
throw new Error('You cannot create a history object');
}
/**
* Clients usually do not need to modify existing history objects
* @ignore
*/
public async update(): Promise<void> {
throw new Error('You cannot update a history object');
}
/**
* Sends a post-request to delete history objects
*/
public async delete(): Promise<void> {
const restPath = 'rest/core/history/clear_history/';
await this.httpService.post(restPath);
}
/**
* Get the ListTitle of a history Element from the dataStore
* using the collection string and the ID.
*
* @param collectionString the models collection string
* @param id the models id
* @returns the ListTitle or null if the model was deleted already
*/
public getOldModelInfo(collectionString: string, id: number): string {
const oldModel: BaseModel = this.DS.get(collectionString, id);
if (oldModel) {
return oldModel.getListTitle();
} else {
return null;
}
}
/**
* Creates a new ViewHistory objects out of a historyObject
*
* @param history the source history object
* @return a new ViewHistory object
*/
public createViewModel(history: History): ViewHistory {
const user = this.DS.get(User, history.user_id);
return new ViewHistory(history, user);
}
/**
* Get the full data on the given date and use the
* TimeTravelService to browse the history on the
* given date
*
* @param viewHistory determines to point to travel back to
*/
public async browseHistory(viewHistory: ViewHistory): Promise<void> {
return this.timeTravel.loadHistoryPoint(viewHistory.history);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -41,15 +41,19 @@ import { CreateMotion } from '../models/create-motion';
providedIn: 'root' providedIn: 'root'
}) })
export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion> { export class MotionRepositoryService extends BaseRepository<ViewMotion, Motion> {
/** /**
* Creates a MotionRepository * Creates a MotionRepository
* *
* Converts existing and incoming motions to ViewMotions * Converts existing and incoming motions to ViewMotions
* Handles CRUD using an observer to the DataStore * Handles CRUD using an observer to the DataStore
* @param {DataStoreService} DS *
* @param {DataSendService} dataSend * @param DS The DataStore
* @param {LinenumberingService} lineNumbering * @param mapperService Maps collection strings to classes
* @param {DiffService} diff * @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( public constructor(
DS: DataStoreService, DS: DataStoreService,

View File

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

View File

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

View File

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

View File

@ -15,8 +15,7 @@
} }
.os-logo-container:focus, .os-logo-container:focus,
.os-logo-container:active, .os-logo-container:active,
.os-logo-container:hover, .os-logo-container:hover {
{
border: none; border: none;
outline: none; outline: none;
} }
@ -42,6 +41,31 @@ mat-sidenav-container {
padding-bottom: 70px; 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 { main {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -10,6 +10,8 @@ import { pageTransition, navItemAnim } from 'app/shared/animations';
import { MatDialog, MatSidenav } from '@angular/material'; import { MatDialog, MatSidenav } from '@angular/material';
import { ViewportService } from '../core/services/viewport.service'; import { ViewportService } from '../core/services/viewport.service';
import { MainMenuService } from '../core/services/main-menu.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({ @Component({
selector: 'os-site', selector: 'os-site',
@ -48,11 +50,14 @@ export class SiteComponent extends BaseComponent implements OnInit {
* Constructor * Constructor
* *
* @param authService * @param authService
* @param router * @param route
* @param operator * @param operator
* @param vp * @param vp
* @param translate * @param translate
* @param dialog * @param dialog
* @param mainMenuService
* @param OSStatus
* @param timeTravel
*/ */
public constructor( public constructor(
private authService: AuthService, private authService: AuthService,
@ -61,7 +66,9 @@ export class SiteComponent extends BaseComponent implements OnInit {
public vp: ViewportService, public vp: ViewportService,
public translate: TranslateService, public translate: TranslateService,
public dialog: MatDialog, public dialog: MatDialog,
public mainMenuService: MainMenuService // used in the component public mainMenuService: MainMenuService,
public OSStatus: OpenSlidesStatusService,
public timeTravel: TimeTravelService
) { ) {
super(); super();

View File

@ -26,7 +26,10 @@ export class TagRepositoryService extends BaseRepository<ViewTag, Tag> {
* Creates a TagRepository * Creates a TagRepository
* Converts existing and incoming Tags to ViewTags * Converts existing and incoming Tags to ViewTags
* Handles CRUD using an observer to the DataStore * 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( public constructor(
protected DS: DataStoreService, protected DS: DataStoreService,

View File

@ -33,8 +33,10 @@ export class GroupRepositoryService extends BaseRepository<ViewGroup, Group> {
/** /**
* Constructor calls the parent constructor * Constructor calls the parent constructor
* @param DS Store * @param DS The DataStore
* @param dataSend Sending Data * @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects
* @param constants reading out the OpenSlides constants
*/ */
public constructor( public constructor(
DS: DataStoreService, DS: DataStoreService,

View File

@ -21,7 +21,11 @@ import { TranslateService } from '@ngx-translate/core';
}) })
export class UserRepositoryService extends BaseRepository<ViewUser, User> { export class UserRepositoryService extends BaseRepository<ViewUser, User> {
/** /**
* Constructor calls the parent constructor * Constructor for the user repo
*
* @param DS The DataStore
* @param mapperService Maps collection strings to classes
* @param dataSend sending changed objects
*/ */
public constructor( public constructor(
DS: DataStoreService, DS: DataStoreService,

View File

@ -1,4 +1,5 @@
from ..utils.access_permissions import BaseAccessPermissions from ..utils.access_permissions import BaseAccessPermissions
from ..utils.auth import GROUP_ADMIN_PK, async_in_some_groups
class ProjectorAccessPermissions(BaseAccessPermissions): class ProjectorAccessPermissions(BaseAccessPermissions):
@ -88,3 +89,24 @@ class ConfigAccessPermissions(BaseAccessPermissions):
from .serializers import ConfigSerializer from .serializers import ConfigSerializer
return 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

View File

@ -32,6 +32,7 @@ class CoreAppConfig(AppConfig):
ChatMessageViewSet, ChatMessageViewSet,
ConfigViewSet, ConfigViewSet,
CountdownViewSet, CountdownViewSet,
HistoryViewSet,
ProjectorMessageViewSet, ProjectorMessageViewSet,
ProjectorViewSet, ProjectorViewSet,
TagViewSet, TagViewSet,
@ -81,10 +82,12 @@ class CoreAppConfig(AppConfig):
router.register(self.get_model('ConfigStore').get_collection_string(), ConfigViewSet, 'config') 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('ProjectorMessage').get_collection_string(), ProjectorMessageViewSet)
router.register(self.get_model('Countdown').get_collection_string(), CountdownViewSet) 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: if is_normal_server_start:
element_cache.ensure_cache() element_cache.ensure_cache()
self.get_model('History').objects.build_history()
# Register client messages # Register client messages
register_client_message(NotifyWebsocketClientMessage()) register_client_message(NotifyWebsocketClientMessage())
@ -104,7 +107,7 @@ class CoreAppConfig(AppConfig):
Yields all Cachables required on startup i. e. opening the websocket Yields all Cachables required on startup i. e. opening the websocket
connection. 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) yield self.get_model(model_name)
def get_angular_constants(self): def get_angular_constants(self):

View File

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

View File

@ -1,14 +1,18 @@
from asgiref.sync import async_to_sync
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models, transaction
from django.utils.timezone import now from django.utils.timezone import now
from jsonfield import JSONField 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.models import RESTModelMixin
from ..utils.projector import get_all_projector_elements from ..utils.projector import get_all_projector_elements
from .access_permissions import ( from .access_permissions import (
ChatMessageAccessPermissions, ChatMessageAccessPermissions,
ConfigAccessPermissions, ConfigAccessPermissions,
CountdownAccessPermissions, CountdownAccessPermissions,
HistoryAccessPermissions,
ProjectorAccessPermissions, ProjectorAccessPermissions,
ProjectorMessageAccessPermissions, ProjectorMessageAccessPermissions,
TagAccessPermissions, TagAccessPermissions,
@ -324,3 +328,100 @@ class Countdown(RESTModelMixin, models.Model):
self.running = False self.running = False
self.countdown_time = self.default_time self.countdown_time = self.default_time
self.save(skip_autoupdate=skip_autoupdate) 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 = ()

View File

@ -5,6 +5,7 @@ from .models import (
ChatMessage, ChatMessage,
ConfigStore, ConfigStore,
Countdown, Countdown,
History,
ProjectionDefault, ProjectionDefault,
Projector, Projector,
ProjectorMessage, ProjectorMessage,
@ -110,3 +111,14 @@ class CountdownSerializer(ModelSerializer):
class Meta: class Meta:
model = Countdown model = Countdown
fields = ('id', 'description', 'default_time', 'countdown_time', 'running', ) 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', )

View File

@ -11,4 +11,8 @@ urlpatterns = [
url(r'^version/$', url(r'^version/$',
views.VersionView.as_view(), views.VersionView.as_view(),
name='core_version'), name='core_version'),
url(r'^history/$',
views.HistoryView.as_view(),
name='core_history'),
] ]

View File

@ -1,3 +1,4 @@
import datetime
import os import os
import uuid import uuid
from typing import Any, Dict, List 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 .. import __license__ as license, __url__ as url, __version__ as version
from ..utils import views as utils_views from ..utils import views as utils_views
from ..utils.arguments import arguments 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.autoupdate import inform_changed_data, inform_deleted_data
from ..utils.plugins import ( from ..utils.plugins import (
get_plugin_description, get_plugin_description,
@ -26,8 +32,11 @@ from ..utils.plugins import (
get_plugin_version, get_plugin_version,
) )
from ..utils.rest_api import ( from ..utils.rest_api import (
GenericViewSet,
ListModelMixin,
ModelViewSet, ModelViewSet,
Response, Response,
RetrieveModelMixin,
ValidationError, ValidationError,
detail_route, detail_route,
list_route, list_route,
@ -36,6 +45,7 @@ from .access_permissions import (
ChatMessageAccessPermissions, ChatMessageAccessPermissions,
ConfigAccessPermissions, ConfigAccessPermissions,
CountdownAccessPermissions, CountdownAccessPermissions,
HistoryAccessPermissions,
ProjectorAccessPermissions, ProjectorAccessPermissions,
ProjectorMessageAccessPermissions, ProjectorMessageAccessPermissions,
TagAccessPermissions, TagAccessPermissions,
@ -46,6 +56,8 @@ from .models import (
ChatMessage, ChatMessage,
ConfigStore, ConfigStore,
Countdown, Countdown,
History,
HistoryData,
ProjectionDefault, ProjectionDefault,
Projector, Projector,
ProjectorMessage, ProjectorMessage,
@ -716,6 +728,50 @@ class CountdownViewSet(ModelViewSet):
return result 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 # Special API views
class ServerTime(utils_views.APIView): class ServerTime(utils_views.APIView):
@ -755,3 +811,38 @@ class VersionView(utils_views.APIView):
'license': get_plugin_license(plugin), 'license': get_plugin_license(plugin),
'url': get_plugin_url(plugin)}) 'url': get_plugin_url(plugin)})
return result 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

View File

@ -14,87 +14,103 @@ def create_builtin_workflows(sender, **kwargs):
# If there is at least one workflow, then do nothing. # If there is at least one workflow, then do nothing.
return return
workflow_1 = Workflow.objects.create(name='Simple Workflow') workflow_1 = Workflow(name='Simple Workflow')
state_1_1 = State.objects.create(name=ugettext_noop('submitted'), workflow_1.save(skip_autoupdate=True)
state_1_1 = State(name=ugettext_noop('submitted'),
workflow=workflow_1, workflow=workflow_1,
allow_create_poll=True, allow_create_poll=True,
allow_support=True, allow_support=True,
allow_submitter_edit=True) allow_submitter_edit=True)
state_1_2 = State.objects.create(name=ugettext_noop('accepted'), state_1_1.save(skip_autoupdate=True)
state_1_2 = State(name=ugettext_noop('accepted'),
workflow=workflow_1, workflow=workflow_1,
action_word='Accept', action_word='Accept',
recommendation_label='Acceptance', recommendation_label='Acceptance',
css_class='success', css_class='success',
merge_amendment_into_final=True) merge_amendment_into_final=True)
state_1_3 = State.objects.create(name=ugettext_noop('rejected'), state_1_2.save(skip_autoupdate=True)
state_1_3 = State(name=ugettext_noop('rejected'),
workflow=workflow_1, workflow=workflow_1,
action_word='Reject', action_word='Reject',
recommendation_label='Rejection', recommendation_label='Rejection',
css_class='danger') css_class='danger')
state_1_4 = State.objects.create(name=ugettext_noop('not decided'), state_1_3.save(skip_autoupdate=True)
state_1_4 = State(name=ugettext_noop('not decided'),
workflow=workflow_1, workflow=workflow_1,
action_word='Do not decide', action_word='Do not decide',
recommendation_label='No decision', recommendation_label='No decision',
css_class='default') 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) state_1_1.next_states.add(state_1_2, state_1_3, state_1_4)
workflow_1.first_state = state_1_1 workflow_1.first_state = state_1_1
workflow_1.save() workflow_1.save(skip_autoupdate=True)
workflow_2 = Workflow.objects.create(name='Complex Workflow') workflow_2 = Workflow(name='Complex Workflow')
state_2_1 = State.objects.create(name=ugettext_noop('published'), workflow_2.save(skip_autoupdate=True)
state_2_1 = State(name=ugettext_noop('published'),
workflow=workflow_2, workflow=workflow_2,
allow_support=True, allow_support=True,
allow_submitter_edit=True, allow_submitter_edit=True,
dont_set_identifier=True) dont_set_identifier=True)
state_2_2 = State.objects.create(name=ugettext_noop('permitted'), state_2_1.save(skip_autoupdate=True)
state_2_2 = State(name=ugettext_noop('permitted'),
workflow=workflow_2, workflow=workflow_2,
action_word='Permit', action_word='Permit',
recommendation_label='Permission', recommendation_label='Permission',
allow_create_poll=True, allow_create_poll=True,
allow_submitter_edit=True) allow_submitter_edit=True)
state_2_3 = State.objects.create(name=ugettext_noop('accepted'), state_2_2.save(skip_autoupdate=True)
state_2_3 = State(name=ugettext_noop('accepted'),
workflow=workflow_2, workflow=workflow_2,
action_word='Accept', action_word='Accept',
recommendation_label='Acceptance', recommendation_label='Acceptance',
css_class='success', css_class='success',
merge_amendment_into_final=True) merge_amendment_into_final=True)
state_2_4 = State.objects.create(name=ugettext_noop('rejected'), state_2_3.save(skip_autoupdate=True)
state_2_4 = State(name=ugettext_noop('rejected'),
workflow=workflow_2, workflow=workflow_2,
action_word='Reject', action_word='Reject',
recommendation_label='Rejection', recommendation_label='Rejection',
css_class='danger') css_class='danger')
state_2_5 = State.objects.create(name=ugettext_noop('withdrawed'), state_2_4.save(skip_autoupdate=True)
state_2_5 = State(name=ugettext_noop('withdrawed'),
workflow=workflow_2, workflow=workflow_2,
action_word='Withdraw', action_word='Withdraw',
css_class='default') css_class='default')
state_2_6 = State.objects.create(name=ugettext_noop('adjourned'), state_2_5.save(skip_autoupdate=True)
state_2_6 = State(name=ugettext_noop('adjourned'),
workflow=workflow_2, workflow=workflow_2,
action_word='Adjourn', action_word='Adjourn',
recommendation_label='Adjournment', recommendation_label='Adjournment',
css_class='default') css_class='default')
state_2_7 = State.objects.create(name=ugettext_noop('not concerned'), state_2_6.save(skip_autoupdate=True)
state_2_7 = State(name=ugettext_noop('not concerned'),
workflow=workflow_2, workflow=workflow_2,
action_word='Do not concern', action_word='Do not concern',
recommendation_label='No concernment', recommendation_label='No concernment',
css_class='default') css_class='default')
state_2_8 = State.objects.create(name=ugettext_noop('refered to committee'), state_2_7.save(skip_autoupdate=True)
state_2_8 = State(name=ugettext_noop('refered to committee'),
workflow=workflow_2, workflow=workflow_2,
action_word='Refer to committee', action_word='Refer to committee',
recommendation_label='Referral to committee', recommendation_label='Referral to committee',
css_class='default') css_class='default')
state_2_9 = State.objects.create(name=ugettext_noop('needs review'), state_2_8.save(skip_autoupdate=True)
state_2_9 = State(name=ugettext_noop('needs review'),
workflow=workflow_2, workflow=workflow_2,
action_word='Needs review', action_word='Needs review',
css_class='default') css_class='default')
state_2_10 = State.objects.create(name=ugettext_noop('rejected (not authorized)'), state_2_9.save(skip_autoupdate=True)
state_2_10 = State(name=ugettext_noop('rejected (not authorized)'),
workflow=workflow_2, workflow=workflow_2,
action_word='Reject (not authorized)', action_word='Reject (not authorized)',
recommendation_label='Rejection (not authorized)', recommendation_label='Rejection (not authorized)',
css_class='default') 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_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) 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.first_state = state_2_1
workflow_2.save() workflow_2.save(skip_autoupdate=True)
def get_permission_change_data(sender, permissions, **kwargs): def get_permission_change_data(sender, permissions, **kwargs):

View File

@ -14,7 +14,7 @@ from rest_framework import status
from ..core.config import config from ..core.config import config
from ..core.models import Tag from ..core.models import Tag
from ..utils.auth import has_perm, in_some_groups 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.exceptions import OpenSlidesError
from ..utils.rest_api import ( from ..utils.rest_api import (
CreateModelMixin, CreateModelMixin,
@ -107,7 +107,15 @@ class MotionViewSet(ModelViewSet):
motion.is_submitter(request.user) and motion.state.allow_submitter_edit)): motion.is_submitter(request.user) and motion.state.allow_submitter_edit)):
self.permission_denied(request) 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): def create(self, request, *args, **kwargs):
""" """
@ -279,6 +287,12 @@ class MotionViewSet(ModelViewSet):
new_users = list(updated_motion.supporters.all()) new_users = list(updated_motion.supporters.all())
inform_changed_data(new_users) 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. # We do not add serializer.data to response so nobody gets unrestricted data here.
return Response() return Response()
@ -630,7 +644,7 @@ class MotionViewSet(ModelViewSet):
message_list=[ugettext_noop('State set to'), ' ', motion.state.name], message_list=[ugettext_noop('State set to'), ' ', motion.state.name],
person=request.user, person=request.user,
skip_autoupdate=True) skip_autoupdate=True)
inform_changed_data(motion) inform_changed_data(motion, information='State set to {}.'.format(motion.state.name))
return Response({'detail': message}) return Response({'detail': message})
@detail_route(methods=['put']) @detail_route(methods=['put'])

View File

@ -11,7 +11,7 @@ from django.contrib.auth.models import (
PermissionsMixin, PermissionsMixin,
) )
from django.core import mail 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 import models
from django.db.models import Prefetch from django.db.models import Prefetch
from django.utils import timezone from django.utils import timezone
@ -62,12 +62,18 @@ class UserManager(BaseUserManager):
exists, resets it. The password is (re)set to 'admin'. The user exists, resets it. The password is (re)set to 'admin'. The user
becomes member of the group 'Admin'. becomes member of the group 'Admin'.
""" """
admin, created = self.get_or_create( created = False
try:
admin = self.get(username='admin')
except ObjectDoesNotExist:
admin = self.model(
username='admin', username='admin',
defaults={'last_name': 'Administrator'}) last_name='Administrator',
)
created = True
admin.default_password = 'admin' admin.default_password = 'admin'
admin.password = make_password(admin.default_password) admin.password = make_password(admin.default_password)
admin.save() admin.save(skip_autoupdate=True)
admin.groups.add(GROUP_ADMIN_PK) admin.groups.add(GROUP_ADMIN_PK)
return created return created

View File

@ -3,7 +3,6 @@ from django.contrib.auth.models import Permission
from django.db.models import Q from django.db.models import Q
from ..utils.auth import GROUP_ADMIN_PK, GROUP_DEFAULT_PK from ..utils.auth import GROUP_ADMIN_PK, GROUP_DEFAULT_PK
from ..utils.autoupdate import inform_changed_data
from .models import Group, User from .models import Group, User
@ -81,11 +80,13 @@ def create_builtin_groups_and_admin(**kwargs):
permission_dict['mediafiles.can_see'], permission_dict['mediafiles.can_see'],
permission_dict['motions.can_see'], permission_dict['motions.can_see'],
permission_dict['users.can_see_name'], ) 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) group_default.permissions.add(*base_permissions)
# Admin (pk 2 == GROUP_ADMIN_PK) # 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 (pk 3)
delegates_permissions = ( delegates_permissions = (
@ -102,7 +103,8 @@ def create_builtin_groups_and_admin(**kwargs):
permission_dict['motions.can_create'], permission_dict['motions.can_create'],
permission_dict['motions.can_support'], permission_dict['motions.can_support'],
permission_dict['users.can_see_name'], ) 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) group_delegates.permissions.add(*delegates_permissions)
# Staff (pk 4) # Staff (pk 4)
@ -132,17 +134,10 @@ def create_builtin_groups_and_admin(**kwargs):
permission_dict['users.can_manage'], permission_dict['users.can_manage'],
permission_dict['users.can_see_extra_data'], permission_dict['users.can_see_extra_data'],
permission_dict['mediafiles.can_see_hidden'],) 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) 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 (pk 5)
committees_permissions = ( committees_permissions = (
permission_dict['agenda.can_see'], 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_create'],
permission_dict['motions.can_support'], permission_dict['motions.can_support'],
permission_dict['users.can_see_name'], ) 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) group_committee.permissions.add(*committees_permissions)
# Create or reset admin user # Create or reset admin user
User.objects.create_or_reset_admin_user() User.objects.create_or_reset_admin_user()
# After each group was created, the permissions (many to many fields) where # 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 # added to the group. But we do not have to update the cache by calling
# inform_changed_data(). # inform_changed_data() because the cache is updated on server start.
inform_changed_data((group_default, group_admin, group_delegates, group_staff, group_committee))

View File

@ -336,7 +336,13 @@ class GroupViewSet(ModelViewSet):
for receiver, signal_collections in signal_results: for receiver, signal_collections in signal_results:
for cachable in signal_collections: for cachable in signal_collections:
for full_data in all_full_data.get(cachable.get_collection_string(), {}): 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) inform_changed_elements(elements)
# TODO: Some permissions are deleted. # TODO: Some permissions are deleted.

View File

@ -1,3 +1,4 @@
import itertools
import threading import threading
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
@ -15,6 +16,9 @@ Element = TypedDict(
'id': int, 'id': int,
'collection_string': str, 'collection_string': str,
'full_data': Optional[Dict[str, Any]], '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 Informs the autoupdate system and the caching system about the creation or
update of an element. update of an element.
The argument instances can be one instance or an iterable over instances. The argument instances can be one instance or an iterable over instances.
History creation is enabled.
""" """
root_instances = set() root_instances = set()
if not isinstance(instances, Iterable): if not isinstance(instances, Iterable):
@ -54,7 +63,11 @@ def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None:
elements[key] = Element( elements[key] = Element(
id=root_instance.get_rest_pk(), id=root_instance.get_rest_pk(),
collection_string=root_instance.get_collection_string(), 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()) bundle = autoupdate_bundle.get(threading.get_ident())
if bundle is not None: if bundle is not None:
@ -65,15 +78,27 @@ def inform_changed_data(instances: Union[Iterable[Model], Model]) -> None:
handle_changed_elements(elements.values()) 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 Informs the autoupdate system and the caching system about the deletion of
elements. elements.
History creation is enabled.
""" """
elements: Dict[str, Element] = {} elements: Dict[str, Element] = {}
for deleted_element in deleted_elements: for deleted_element in deleted_elements:
key = deleted_element[0] + str(deleted_element[1]) 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()) bundle = autoupdate_bundle.get(threading.get_ident())
if bundle is not None: 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: def inform_changed_elements(changed_elements: Iterable[Element]) -> None:
""" """
Informs the autoupdate system about some collection elements. This is Informs the autoupdate system about some elements. This is used just to send
used just to send some data to all users. 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 = {} elements = {}
for changed_element in changed_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. 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. 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'] cache_elements[element_id] = element['full_data']
return await element_cache.change_elements(cache_elements) 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. Async helper function to update cache and send autoupdate.
""" """
# Update cache # Update cache
change_id = await update_cache() change_id = await update_cache(elements)
# Send autoupdate # Send autoupdate
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
@ -165,7 +193,36 @@ def handle_changed_elements(elements: Iterable[Element]) -> None:
) )
if elements: 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 # Convert history instances to Elements.
async_to_sync(async_handle_collection_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)

View File

@ -53,8 +53,8 @@ def register_projector_elements(elements: Generator[Type[ProjectorElement], None
Has to be called in the app.ready method. Has to be called in the app.ready method.
""" """
for Element in elements: for AppProjectorElement in elements:
element = Element() element = AppProjectorElement()
projector_elements[element.name] = element # type: ignore projector_elements[element.name] = element # type: ignore

View File

@ -1,12 +1,10 @@
from typing import Any, Dict, List from typing import Any, Dict, List
from asgiref.sync import sync_to_async
from django.db import DEFAULT_DB_ALIAS, connections from django.db import DEFAULT_DB_ALIAS, connections
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from openslides.core.config import config from openslides.core.config import config
from openslides.users.models import User from openslides.users.models import User
from openslides.utils.autoupdate import Element, inform_changed_elements
class TConfig: class TConfig:
@ -55,17 +53,6 @@ class TUser:
return elements 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: def count_queries(func, *args, **kwargs) -> int:
context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS]) context = CaptureQueriesContext(connections[DEFAULT_DB_ALIAS])
with context: with context:

View File

@ -1,5 +1,6 @@
import asyncio import asyncio
from importlib import import_module from importlib import import_module
from unittest.mock import patch
import pytest import pytest
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
@ -13,7 +14,11 @@ from django.contrib.auth import (
from openslides.asgi import application from openslides.asgi import application
from openslides.core.config import config 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 openslides.utils.cache import element_cache
from ...unit.utils.cache_provider import ( from ...unit.utils.cache_provider import (
@ -21,7 +26,7 @@ from ...unit.utils.cache_provider import (
Collection2, Collection2,
get_cachable_provider, get_cachable_provider,
) )
from ..helpers import TConfig, TUser, set_config from ..helpers import TConfig, TUser
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@ -64,15 +69,31 @@ async def communicator(get_communicator):
yield 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 @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) await set_config('general_system_enable_anonymous', True)
connected, __ = await get_communicator().connect() connected, __ = await get_communicator().connect()
assert connected assert connected
@pytest.mark.asyncio @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) await set_config('general_system_enable_anonymous', True)
communicator = get_communicator('change_id=0') communicator = get_communicator('change_id=0')
await communicator.connect() await communicator.connect()
@ -93,7 +114,7 @@ async def test_connection_with_change_id(get_communicator):
@pytest.mark.asyncio @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 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 @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) await set_config('general_system_enable_anonymous', True)
communicator = get_communicator('change_id=invalid') communicator = get_communicator('change_id=invalid')
connected, __ = await communicator.connect() connected, __ = await communicator.connect()
@ -125,7 +146,7 @@ async def test_connection_with_invalid_change_id(get_communicator):
@pytest.mark.asyncio @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) await set_config('general_system_enable_anonymous', True)
communicator = get_communicator('change_id=100') communicator = get_communicator('change_id=100')
@ -136,7 +157,7 @@ async def test_connection_with_to_big_change_id(get_communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -146,7 +167,7 @@ async def test_changed_data_autoupdate_off(communicator):
@pytest.mark.asyncio @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) await set_config('general_system_enable_anonymous', True)
communicator = get_communicator('autoupdate=on') communicator = get_communicator('autoupdate=on')
await communicator.connect() await communicator.connect()
@ -191,12 +212,13 @@ async def test_with_user():
@pytest.mark.asyncio @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) await set_config('general_system_enable_anonymous', True)
communicator = get_communicator('autoupdate=on') communicator = get_communicator('autoupdate=on')
await communicator.connect() await communicator.connect()
# Delete test element # Delete test element
with patch('openslides.utils.autoupdate.save_history'):
await sync_to_async(inform_deleted_data)([(Collection1().get_collection_string(), 1)]) await sync_to_async(inform_deleted_data)([(Collection1().get_collection_string(), 1)])
response = await communicator.receive_json_from() response = await communicator.receive_json_from()
@ -207,7 +229,7 @@ async def test_receive_deleted_data(get_communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -223,7 +245,7 @@ async def test_send_notify(communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -234,7 +256,7 @@ async def test_invalid_websocket_message_type(communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -245,7 +267,7 @@ async def test_invalid_websocket_message_no_id(communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -257,7 +279,7 @@ async def test_send_unknown_type(communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -270,7 +292,7 @@ async def test_request_constants(communicator, settings):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -291,7 +313,7 @@ async def test_send_get_elements(communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -304,7 +326,7 @@ async def test_send_get_elements_to_big_change_id(communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -318,7 +340,7 @@ async def test_send_get_elements_to_small_change_id(communicator):
@pytest.mark.asyncio @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 Test, that a second request with change_id+1 from the first request, returns
an error. an error.
@ -338,7 +360,7 @@ async def test_send_connect_twice_with_clear_change_id_cache(communicator):
@pytest.mark.asyncio @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 Test, that a second request with the change_id from the first request, returns
all data. all data.
@ -360,7 +382,7 @@ async def test_send_connect_twice_with_clear_change_id_cache_same_change_id_then
@pytest.mark.asyncio @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 Test, that when an elements is changed twice, it is only returned
onces when ask a range of change ids. 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 @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -399,7 +421,7 @@ async def test_send_invalid_get_elements(communicator):
@pytest.mark.asyncio @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 set_config('general_system_enable_anonymous', True)
await communicator.connect() await communicator.connect()
@ -418,7 +440,7 @@ async def test_turn_on_autoupdate(communicator):
@pytest.mark.asyncio @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) await set_config('general_system_enable_anonymous', True)
communicator = get_communicator('autoupdate=on') communicator = get_communicator('autoupdate=on')
await communicator.connect() await communicator.connect()

View File

@ -21,7 +21,8 @@ class MotionViewSetUpdate(TestCase):
@patch('openslides.motions.views.has_perm') @patch('openslides.motions.views.has_perm')
@patch('openslides.motions.views.config') @patch('openslides.motions.views.config')
def test_simple_update(self, mock_config, mock_has_perm, mock_icd): 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() self.request.data.get.return_value = MagicMock()
mock_has_perm.return_value = True mock_has_perm.return_value = True

View File

@ -1,6 +1,8 @@
from unittest import TestCase from unittest import TestCase
from unittest.mock import MagicMock, call, patch from unittest.mock import MagicMock, call, patch
from django.core.exceptions import ObjectDoesNotExist
from openslides.users.models import UserManager from openslides.users.models import UserManager
@ -127,7 +129,7 @@ class UserManagerCreateOrResetAdminUser(TestCase):
""" """
admin_user = MagicMock() admin_user = MagicMock()
manager = UserManager() 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() manager.create_or_reset_admin_user()
@ -139,7 +141,7 @@ class UserManagerCreateOrResetAdminUser(TestCase):
""" """
admin_user = MagicMock() admin_user = MagicMock()
manager = UserManager() manager = UserManager()
manager.get_or_create = MagicMock(return_value=(admin_user, False)) manager.get = MagicMock(return_value=(admin_user))
staff_group = MagicMock(name="Staff") staff_group = MagicMock(name="Staff")
mock_group.objects.get_or_create = MagicMock(return_value=(staff_group, True)) mock_group.objects.get_or_create = MagicMock(return_value=(staff_group, True))
@ -150,16 +152,15 @@ class UserManagerCreateOrResetAdminUser(TestCase):
self.assertEqual( self.assertEqual(
admin_user.default_password, admin_user.default_password,
'admin') 'admin')
admin_user.save.assert_called_once_with() admin_user.save.assert_called_once_with(skip_autoupdate=True)
@patch('openslides.users.models.User') @patch('openslides.users.models.User')
def test_return_value(self, mock_user, mock_group, mock_permission): def test_return_value(self, mock_user, mock_group, mock_permission):
""" """
Tests that the method returns True when a user is created. Tests that the method returns True when a user is created.
""" """
admin_user = MagicMock()
manager = UserManager() manager = UserManager()
manager.get_or_create = MagicMock(return_value=(admin_user, True)) manager.get = MagicMock(side_effect=ObjectDoesNotExist)
manager.model = mock_user manager.model = mock_user
staff_group = MagicMock(name="Staff") staff_group = MagicMock(name="Staff")
@ -179,7 +180,7 @@ class UserManagerCreateOrResetAdminUser(TestCase):
""" """
admin_user = MagicMock(username='admin', last_name='Administrator') admin_user = MagicMock(username='admin', last_name='Administrator')
manager = UserManager() manager = UserManager()
manager.get_or_create = MagicMock(return_value=(admin_user, True)) manager.get = MagicMock(side_effect=ObjectDoesNotExist)
manager.model = mock_user manager.model = mock_user
staff_group = MagicMock(name="Staff") staff_group = MagicMock(name="Staff")