Merge pull request #3977 from normanjaeckel/HistoryModel
OpenSlides history mode
This commit is contained in:
commit
c416f7a1be
@ -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].
|
||||||
|
30
client/src/app/core/query-params.ts
Normal file
30
client/src/app/core/query-params.ts
Normal 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;
|
||||||
|
}
|
@ -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
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
}));
|
||||||
|
});
|
42
client/src/app/core/services/openslides-status.service.ts
Normal file
42
client/src/app/core/services/openslides-status.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
16
client/src/app/core/services/time-travel.service.spec.ts
Normal file
16
client/src/app/core/services/time-travel.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
119
client/src/app/core/services/time-travel.service.ts
Normal file
119
client/src/app/core/services/time-travel.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
39
client/src/app/shared/models/core/history.ts
Normal file
39
client/src/app/shared/models/core/history.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
@ -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
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
17
client/src/app/site/history/history-routing.module.ts
Normal file
17
client/src/app/site/history/history-routing.module.ts
Normal 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 {}
|
20
client/src/app/site/history/history.config.ts
Normal file
20
client/src/app/site/history/history.config.ts
Normal 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'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
16
client/src/app/site/history/history.module.ts
Normal file
16
client/src/app/site/history/history.module.ts
Normal 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 {}
|
132
client/src/app/site/history/models/view-history.ts
Normal file
132
client/src/app/site/history/models/view-history.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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();
|
||||||
|
});
|
||||||
|
});
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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]
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
|
@ -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();
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
54
openslides/core/migrations/0009_auto_20181118_2126.py
Normal file
54
openslides/core/migrations/0009_auto_20181118_2126.py
Normal 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),
|
||||||
|
),
|
||||||
|
]
|
@ -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 = ()
|
||||||
|
@ -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', )
|
||||||
|
@ -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'),
|
||||||
]
|
]
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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'])
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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))
|
|
||||||
|
@ -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.
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -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()
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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")
|
||||||
|
Loading…
Reference in New Issue
Block a user