Merge pull request #3948 from FinnStutzenstein/changeIdClient

ChangeId for the client
This commit is contained in:
Finn Stutzenstein 2018-11-08 08:38:42 +01:00 committed by GitHub
commit 85b91e7101
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 484 additions and 362 deletions

View File

@ -10,7 +10,7 @@
"e2e": "ng e2e", "e2e": "ng e2e",
"compodoc": "./node_modules/.bin/compodoc --hideGenerator -p src/tsconfig.app.json -n 'OpenSlides Documentation' -d ../Compodoc -s -w -t -o --port", "compodoc": "./node_modules/.bin/compodoc --hideGenerator -p src/tsconfig.app.json -n 'OpenSlides Documentation' -d ../Compodoc -s -w -t -o --port",
"extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/{en,de,fr}.json --clean --sort --format-indentation ' ' --format namespaced-json", "extract": "ngx-translate-extract -i ./src -o ./src/assets/i18n/{en,de,fr}.json --clean --sort --format-indentation ' ' --format namespaced-json",
"format:fix": "pretty-quick --staged" "prettify": "pretty-quick"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {

View File

@ -1,13 +1,12 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
import { OperatorService } from 'app/core/services/operator.service'; import { OperatorService } from 'app/core/services/operator.service';
import { OpenSlidesComponent } from '../../openslides.component'; import { OpenSlidesComponent } from '../../openslides.component';
import { environment } from 'environments/environment'; import { environment } from 'environments/environment';
import { User } from '../../shared/models/users/user'; import { User } from '../../shared/models/users/user';
import { OpenSlidesService } from './openslides.service'; import { OpenSlidesService } from './openslides.service';
import { Router } from '@angular/router';
import { HttpService } from './http.service';
/** /**
* The data returned by a post request to the login route. * The data returned by a post request to the login route.
@ -28,13 +27,16 @@ export class AuthService extends OpenSlidesComponent {
* Initializes the httpClient and the {@link OperatorService}. * Initializes the httpClient and the {@link OperatorService}.
* *
* Calls `super()` from the parent class. * Calls `super()` from the parent class.
* @param http HttpClient * @param http HttpService to send requests to the server
* @param operator who is using OpenSlides * @param operator Who is using OpenSlides
* @param OpenSlides The openslides service
* @param router To navigate
*/ */
public constructor( public constructor(
private http: HttpClient, private http: HttpService,
private operator: OperatorService, private operator: OperatorService,
private OpenSlides: OpenSlidesService private OpenSlides: OpenSlidesService,
private router: Router
) { ) {
super(); super();
} }
@ -48,18 +50,16 @@ export class AuthService extends OpenSlidesComponent {
* *
* @param username * @param username
* @param password * @param password
* @returns The login response.
*/ */
public login(username: string, password: string): Observable<LoginResponse> { public async login(username: string, password: string): Promise<LoginResponse> {
const user = { const user = {
username: username, username: username,
password: password password: password
}; };
return this.http.post<LoginResponse>(environment.urlPrefix + '/users/login/', user).pipe( const response = await this.http.post<LoginResponse>(environment.urlPrefix + '/users/login/', user);
tap((response: LoginResponse) => {
this.operator.user = new User(response.user); this.operator.user = new User(response.user);
}), return response;
catchError(this.handleError())
) as Observable<LoginResponse>;
} }
/** /**
@ -68,10 +68,14 @@ export class AuthService extends OpenSlidesComponent {
* Will clear the current {@link OperatorService} and * Will clear the current {@link OperatorService} and
* send a `post`-request to `/apps/users/logout/'` * send a `post`-request to `/apps/users/logout/'`
*/ */
public logout(): void { public async logout(): Promise<void> {
this.operator.user = null; this.operator.user = null;
this.http.post<any>(environment.urlPrefix + '/users/logout/', {}).subscribe(() => { try {
await this.http.post(environment.urlPrefix + '/users/logout/', {});
} catch (e) {
// We do nothing on failures. Reboot OpenSlides anyway.
}
this.router.navigate(['/']);
this.OpenSlides.reboot(); this.OpenSlides.reboot();
});
} }
} }

View File

@ -5,6 +5,7 @@ import { WebsocketService } from './websocket.service';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
import { DataStoreService } from './data-store.service'; import { DataStoreService } from './data-store.service';
import { BaseModel } from '../../shared/models/base/base-model';
interface AutoupdateFormat { interface AutoupdateFormat {
/** /**
@ -22,9 +23,19 @@ interface AutoupdateFormat {
}; };
/** /**
* The current change id for this autoupdate * The lower change id bond for this autoupdate
*/ */
change_id: number; from_change_id: number;
/**
* The upper change id bound for this autoupdate
*/
to_change_id: number;
/**
* Flag, if this autoupdate contains all data. If so, the DS needs to be resetted.
*/
all_data: boolean;
} }
/** /**
@ -41,14 +52,16 @@ export class AutoupdateService extends OpenSlidesComponent {
/** /**
* Constructor to create the AutoupdateService. Calls the constructor of the parent class. * Constructor to create the AutoupdateService. Calls the constructor of the parent class.
* @param websocketService * @param websocketService
* @param DS
* @param modelMapper
*/ */
public constructor( public constructor(
websocketService: WebsocketService, private websocketService: WebsocketService,
private DS: DataStoreService, private DS: DataStoreService,
private modelMapper: CollectionStringModelMapperService private modelMapper: CollectionStringModelMapperService
) { ) {
super(); super();
websocketService.getOberservable<AutoupdateFormat>('autoupdate').subscribe(response => { this.websocketService.getOberservable<AutoupdateFormat>('autoupdate').subscribe(response => {
this.storeResponse(response); this.storeResponse(response);
}); });
} }
@ -56,37 +69,87 @@ export class AutoupdateService extends OpenSlidesComponent {
/** /**
* Handle the answer of incoming data via {@link WebsocketService}. * Handle the answer of incoming data via {@link WebsocketService}.
* *
* Bundles the data per action and collection. THis speeds up the caching in the DataStore.
*
* Detects the Class of an incomming model, creates a new empty object and assigns * Detects the Class of an incomming model, creates a new empty object and assigns
* the data to it using the deserialize function. * the data to it using the deserialize function. Also models that are flagged as deleted
* will be removed from the data store.
* *
* Saves models in DataStore. * Handles the change ids of all autoupdates.
*/ */
public storeResponse(autoupdate: AutoupdateFormat): void { private async storeResponse(autoupdate: AutoupdateFormat): Promise<void> {
// Delete the removed objects from the DataStore console.log('got autoupdate', autoupdate);
Object.keys(autoupdate.deleted).forEach(collection => {
this.DS.remove(collection, autoupdate.deleted[collection], autoupdate.change_id); if (autoupdate.all_data) {
await this.storeAllData(autoupdate);
} else {
await this.storePartialAutoupdate(autoupdate);
}
}
/**
* Stores all data from the autoupdate. This means, that the DS is resettet and filled with just the
* given data from the autoupdate.
* @param autoupdate The autoupdate
*/
private async storeAllData(autoupdate: AutoupdateFormat): Promise<void> {
let elements: BaseModel[] = [];
Object.keys(autoupdate.changed).forEach(collection => {
elements = elements.concat(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection]));
}); });
await this.DS.set(elements, autoupdate.to_change_id);
}
/**
* handles a normal autoupdate that is not a full update (all_data=false).
* @param autoupdate The autoupdate
*/
private async storePartialAutoupdate(autoupdate: AutoupdateFormat): Promise<void> {
const maxChangeId = this.DS.maxChangeId;
if (autoupdate.from_change_id <= maxChangeId && autoupdate.to_change_id <= maxChangeId) {
console.log('ignore');
return; // Ignore autoupdates, that lay full behind our changeid.
}
// Normal autoupdate
if (autoupdate.from_change_id <= maxChangeId + 1 && autoupdate.to_change_id > maxChangeId) {
// Delete the removed objects from the DataStore
for (const collection of Object.keys(autoupdate.deleted)) {
await this.DS.remove(collection, autoupdate.deleted[collection]);
}
// Add the objects to the DataStore. // Add the objects to the DataStore.
Object.keys(autoupdate.changed).forEach(collection => { for (const collection of Object.keys(autoupdate.changed)) {
await this.DS.add(this.mapObjectsToBaseModels(collection, autoupdate.changed[collection]));
}
console.log('new max change id', autoupdate.to_change_id);
await this.DS.flushToStorage(autoupdate.to_change_id);
} else {
// autoupdate fully in the future. we are missing something!
this.requestChanges();
}
}
/**
* Creates baseModels for each plain object
* @param collection The collection all models have to be from.
* @param models All models that should be mapped to BaseModels
* @returns A list of basemodels constructed from the given models.
*/
private mapObjectsToBaseModels(collection: string, models: object[]): BaseModel[] {
const targetClass = this.modelMapper.getModelConstructor(collection); const targetClass = this.modelMapper.getModelConstructor(collection);
if (!targetClass) { if (!targetClass) {
throw new Error(`Unregistered resource ${collection}`); throw new Error(`Unregistered resource ${collection}`);
} }
this.DS.add(autoupdate.changed[collection].map(model => new targetClass(model)), autoupdate.change_id); return models.map(model => new targetClass(model));
});
} }
/** /**
* Sends a WebSocket request to the Server with the maxChangeId of the DataStore. * Sends a WebSocket request to the Server with the maxChangeId of the DataStore.
* The server should return an autoupdate with all new data. * The server should return an autoupdate with all new data.
*
* TODO: Wait for changeIds to be implemented on the server.
*/ */
public requestChanges(): void { public requestChanges(): void {
console.log('requesting changed objects'); console.log('requesting changed objects with DS max change id', this.DS.maxChangeId + 1);
// this.websocketService.send('changeIdRequest', this.DS.maxChangeId); this.websocketService.send('getElements', { change_id: this.DS.maxChangeId + 1 });
} }
} }

View File

@ -1,15 +0,0 @@
import { TestBed, inject } from '@angular/core/testing';
import { CacheService } from './cache.service';
describe('WebsocketService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [CacheService]
});
});
it('should be created', inject([CacheService], (service: CacheService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -1,151 +0,0 @@
import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage';
import { Observable } from 'rxjs';
/**
* Container objects for the setQueue.
*/
interface SetContainer {
key: string;
item: any;
callback: (value: boolean) => void;
}
/**
* Container objects for the removeQueue.
*/
interface RemoveContainer {
key: string;
callback: (value: boolean) => void;
}
/**
* Provides an async API to an key-value store using ngx-pwa which is internally
* using IndexedDB or localStorage as a fallback.
*/
@Injectable({
providedIn: 'root'
})
export class CacheService {
/**
* The queue of waiting set requests. Just one request (with the same key, which is
* an often case) at the time can be handeled. The SetContainer encapsulates the key,
* item and callback.
*/
private setQueue: SetContainer[] = [];
/**
* The queue of waiting remove requests. Same reason for the queue es the @name _setQueue.
*/
private removeQueue: RemoveContainer[] = [];
/**
* Constructor to create the CacheService. Needs the localStorage service.
* @param localStorage
*/
public constructor(private localStorage: LocalStorage) {}
/**
* Sets the item into the store asynchronously.
* @param key
* @param item
* @param callback An optional callback that is called on success
*/
public set(key: string, item: any, callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
// Put the set request into the queue
const queueObj: SetContainer = {
key: key,
item: item,
callback: callback
};
this.setQueue.unshift(queueObj);
// If this is the only object, put it into the cache.
if (this.setQueue.length === 1) {
this.localStorage.setItem(key, item).subscribe(this._setCallback.bind(this), this._error);
}
}
/**
* gets called, if a set of the first item in the queue was successful.
* @param value success
*/
private _setCallback(success: boolean): void {
// Call the callback and remove the object from the queue
this.setQueue[0].callback(success);
this.setQueue.pop();
// If there are objects left, insert the first one into the cache.
if (this.setQueue.length > 0) {
const queueObj = this.setQueue[0];
this.localStorage.setItem(queueObj.key, queueObj.item).subscribe(this._setCallback.bind(this), this._error);
}
}
/**
* get a value from the store. You need to subscribe to the request to retrieve the value.
* @param key The key to get the value from
*/
public get<T>(key: string): Observable<T> {
return this.localStorage.getItem<T>(key);
}
/**
* Remove the key from the store.
* @param key The key to remove the value from
* @param callback An optional callback that is called on success
*/
public remove(key: string, callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
// Put the remove request into the queue
const queueObj: RemoveContainer = {
key: key,
callback: callback
};
this.removeQueue.unshift(queueObj);
// If this is the only object, remove it from the cache.
if (this.removeQueue.length === 1) {
this.localStorage.removeItem(key).subscribe(this._removeCallback.bind(this), this._error);
}
}
/**
* gets called, if a remove of the first item in the queue was successfull.
* @param value success
*/
private _removeCallback(success: boolean): void {
// Call the callback and remove the object from the queue
this.removeQueue[0].callback(success);
this.removeQueue.pop();
// If there are objects left, remove the first one from the cache.
if (this.removeQueue.length > 0) {
const queueObj = this.removeQueue[0];
this.localStorage.removeItem(queueObj.key).subscribe(this._removeCallback.bind(this), this._error);
}
}
/**
* Clear the whole cache
* @param callback An optional callback that is called on success
*/
public clear(callback?: (value: boolean) => void): void {
if (!callback) {
callback = () => {};
}
this.localStorage.clear().subscribe(callback, this._error);
}
/**
* First error catching function.
*/
private _error(): void {
console.error('caching error', arguments);
}
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@angular/core';
import { Observable, Subject } from 'rxjs'; import { Observable, Subject } from 'rxjs';
import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model'; import { BaseModel, ModelConstructor } from '../../shared/models/base/base-model';
import { CacheService } from './cache.service'; import { StorageService } from './storage.service';
import { CollectionStringModelMapperService } from './collectionStringModelMapper.service'; import { CollectionStringModelMapperService } from './collectionStringModelMapper.service';
/** /**
@ -64,7 +64,7 @@ export class DataStoreService {
* all cases equal! * all cases equal!
*/ */
private modelStore: ModelStorage = {}; private modelStore: ModelStorage = {};
private JsonStore: JsonStorage = {}; private jsonStore: JsonStorage = {};
/** /**
* Observable subject for changed models in the datastore. * Observable subject for changed models in the datastore.
@ -107,42 +107,51 @@ export class DataStoreService {
} }
/** /**
* Empty constructor for dataStore * @param storageService use StorageService to preserve the DataStore.
* @param cacheService use CacheService to cache the DataStore. * @param modelMapper
*/ */
public constructor(private cacheService: CacheService, private modelMapper: CollectionStringModelMapperService) {} public constructor(
private storageService: StorageService,
private modelMapper: CollectionStringModelMapperService
) {}
/** /**
* Gets the DataStore from cache and instantiate all models out of the serialized version. * Gets the DataStore from cache and instantiate all models out of the serialized version.
* @returns The max change id.
*/ */
public initFromCache(): Promise<number> { public async initFromStorage(): Promise<number> {
// This promise will be resolved with the maximal change id of the cache. // This promise will be resolved with the maximal change id of the cache.
return new Promise<number>(resolve => { const store = await this.storageService.get<JsonStorage>(DataStoreService.cachePrefix + 'DS');
this.cacheService.get<JsonStorage>(DataStoreService.cachePrefix + 'DS').subscribe((store: JsonStorage) => { if (store) {
if (store != null) { console.log('init from storage:', store);
// There is a store. Deserialize it // There is a store. Deserialize it
this.JsonStore = store; this.jsonStore = store;
this.modelStore = this.deserializeJsonStore(this.JsonStore); this.modelStore = this.deserializeJsonStore(this.jsonStore);
// Get the maxChangeId from the cache // Get the maxChangeId from the cache
this.cacheService let maxChangeId = await this.storageService.get<number>(DataStoreService.cachePrefix + 'maxChangeId');
.get<number>(DataStoreService.cachePrefix + 'maxChangeId') if (!maxChangeId) {
.subscribe((maxChangeId: number) => {
if (maxChangeId == null) {
maxChangeId = 0; maxChangeId = 0;
} }
this._maxChangeId = maxChangeId; this._maxChangeId = maxChangeId;
resolve(maxChangeId);
// update observers
Object.keys(this.modelStore).forEach(collection => {
Object.keys(this.modelStore[collection]).forEach(id => {
this.changedSubject.next(this.modelStore[collection][id]);
});
}); });
} else { } else {
// No store here, so get all data from the server. this.jsonStore = {};
resolve(0); this.modelStore = {};
this._maxChangeId = 0;
} }
}); return this.maxChangeId;
});
} }
/** /**
* Deserialze the given serializedStorage and returns a Storage. * Deserialze the given serializedStorage and returns a Storage.
* @param serializedStore The store to deserialize
* @returns The serialized storage
*/ */
private deserializeJsonStore(serializedStore: JsonStorage): ModelStorage { private deserializeJsonStore(serializedStore: JsonStorage): ModelStorage {
const storage: ModelStorage = {}; const storage: ModelStorage = {};
@ -161,17 +170,21 @@ export class DataStoreService {
/** /**
* Clears the complete DataStore and Cache. * Clears the complete DataStore and Cache.
* @param callback
*/ */
public clear(callback?: (value: boolean) => void): void { public async clear(): Promise<void> {
console.log('DS clear');
this.modelStore = {}; this.modelStore = {};
this.JsonStore = {}; this.jsonStore = {};
this._maxChangeId = 0; this._maxChangeId = 0;
this.cacheService.remove(DataStoreService.cachePrefix + 'DS', () => { await this.storageService.remove(DataStoreService.cachePrefix + 'DS');
this.cacheService.remove(DataStoreService.cachePrefix + 'maxChangeId', callback); await this.storageService.remove(DataStoreService.cachePrefix + 'maxChangeId');
});
} }
/**
* Returns the collection _string_ based on the model given. If a string is given, it's just returned.
* @param collectionType Either a Model constructor or a string.
* @returns the collection string
*/
private getCollectionString<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string): string { private getCollectionString<T extends BaseModel<T>>(collectionType: ModelConstructor<T> | string): string {
if (typeof collectionType === 'string') { if (typeof collectionType === 'string') {
return collectionType; return collectionType;
@ -262,62 +275,78 @@ export class DataStoreService {
* Add one or multiple models to dataStore. * Add one or multiple models to dataStore.
* *
* @param models BaseModels to add to the store * @param models BaseModels to add to the store
* @param changeId The changeId of this update * @param changeId The changeId of this update. If given, the storage will be flushed to the
* @example this.DS.add([new User(1)], changeId) * cache. Else one can call {@method flushToStorage} to do this manually.
* @example this.DS.add([new User(2), new User(3)], changeId) * @example this.DS.add([new User(1)])
* @example this.DS.add([new User(2), new User(3)])
* @example this.DS.add(arrayWithUsers, changeId) * @example this.DS.add(arrayWithUsers, changeId)
*/ */
public add(models: BaseModel[], changeId: number): void { public async add(models: BaseModel[], changeId?: number): Promise<void> {
models.forEach(model => { models.forEach(model => {
const collectionString = model.collectionString; const collection = model.collectionString;
if (this.modelStore[collectionString] === undefined) { if (this.modelStore[collection] === undefined) {
this.modelStore[collectionString] = {}; this.modelStore[collection] = {};
} }
this.modelStore[collectionString][model.id] = model; this.modelStore[collection][model.id] = model;
if (this.JsonStore[collectionString] === undefined) { if (this.jsonStore[collection] === undefined) {
this.JsonStore[collectionString] = {}; this.jsonStore[collection] = {};
} }
this.JsonStore[collectionString][model.id] = JSON.stringify(model); this.jsonStore[collection][model.id] = JSON.stringify(model);
this.changedSubject.next(model); this.changedSubject.next(model);
}); });
this.storeToCache(changeId); if (changeId) {
await this.flushToStorage(changeId);
}
} }
/** /**
* removes one or multiple models from dataStore. * removes one or multiple models from dataStore.
* *
* @param Type The desired BaseModel type to be read from the datastore * @param collectionString The desired BaseModel type to be removed from the datastore
* @param ids A list of IDs of BaseModels to remove from the datastore * @param ids A list of IDs of BaseModels to remove from the datastore
* @param changeId The changeId of this update * @param changeId The changeId of this update. If given, the storage will be flushed to the
* @example this.DS.remove('users/user', [myUser.id, 3, 4], 38213) * cache. Else one can call {@method flushToStorage} to do this manually.
* @example this.DS.remove('users/user', [myUser.id, 3, 4])
*/ */
public remove(collectionString: string, ids: number[], changeId: number): void { public async remove(collectionString: string, ids: number[], changeId?: number): Promise<void> {
ids.forEach(id => { ids.forEach(id => {
if (this.modelStore[collectionString]) { if (this.modelStore[collectionString]) {
delete this.modelStore[collectionString][id]; delete this.modelStore[collectionString][id];
} }
if (this.JsonStore[collectionString]) { if (this.jsonStore[collectionString]) {
delete this.JsonStore[collectionString][id]; delete this.jsonStore[collectionString][id];
} }
this.deletedSubject.next({ this.deletedSubject.next({
collection: collectionString, collection: collectionString,
id: id id: id
}); });
}); });
this.storeToCache(changeId); if (changeId) {
await this.flushToStorage(changeId);
}
}
/**
* Resets the DataStore and set the given models as the new content.
* @param models A list of models to set the DataStore to.
* @param newMaxChangeId Optional. If given, the max change id will be updated.
*/
public async set(models: BaseModel[], newMaxChangeId?: number): Promise<void> {
this.modelStore = {};
this.jsonStore = {};
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
* @param changeId The changeId from the update. If it's the highest change id seen, it will be set into the cache. * @param changeId The changeId from the update. If it's the highest change id seen, it will be set into the cache.
*/ */
private storeToCache(changeId: number): void { public async flushToStorage(changeId: number): Promise<void> {
this.cacheService.set(DataStoreService.cachePrefix + 'DS', this.JsonStore); console.log('flush to storage');
if (changeId > this._maxChangeId) {
this._maxChangeId = changeId; this._maxChangeId = changeId;
this.cacheService.set(DataStoreService.cachePrefix + 'maxChangeId', changeId); await this.storageService.set(DataStoreService.cachePrefix + 'DS', this.jsonStore);
} await this.storageService.set(DataStoreService.cachePrefix + 'maxChangeId', changeId);
} }
/** /**

View File

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

View File

@ -0,0 +1,68 @@
import { Injectable } from '@angular/core';
import { OpenSlidesComponent } from 'app/openslides.component';
import { DataStoreService } from './data-store.service';
import { WhoAmIResponse } from './operator.service';
/**
* This service handles everything connected with being offline.
*
* TODO: This is just a stub. Needs to be done in the future; Maybe we cancel this whole concept
* of this service. We'll see whats happens here..
*/
@Injectable({
providedIn: 'root'
})
export class OfflineService extends OpenSlidesComponent {
private _offline = false;
public get offline(): boolean {
return this._offline;
}
/**
* Constructor to create the AutoupdateService. Calls the constructor of the parent class.
* @param DS
*/
public constructor(private DS: DataStoreService) {
super();
}
/**
* Sets the offline flag. Restores the DataStoreService to the last known configuration.
*/
public async goOfflineBecauseFailedWhoAmI(): Promise<void> {
this._offline = true;
console.log('offline because whoami failed.');
// TODO: Init the DS from cache.
await this.DS.clear();
}
/**
* TODO: Should be somehow connected to the websocket service.
*/
public goOfflineBecauseConnectionLost(): void {
this._offline = true;
console.log('offline because connection lost.');
}
/**
* TODO: Should be somehow connected to the websocket service.
*/
public goOnline(): void {
this._offline = false;
}
/**
* Returns the last cached WhoAmI response.
*/
public getLastWhoAmI(): WhoAmIResponse {
// TODO: use a cached WhoAmI response.
return {
user_id: null,
guest_enabled: false,
user: null
};
}
}

View File

@ -4,7 +4,7 @@ import { Router } from '@angular/router';
import { OpenSlidesComponent } from 'app/openslides.component'; import { OpenSlidesComponent } from 'app/openslides.component';
import { WebsocketService } from './websocket.service'; import { WebsocketService } from './websocket.service';
import { OperatorService } from './operator.service'; import { OperatorService } from './operator.service';
import { CacheService } from './cache.service'; import { StorageService } from './storage.service';
import { AutoupdateService } from './autoupdate.service'; import { AutoupdateService } from './autoupdate.service';
import { DataStoreService } from './data-store.service'; import { DataStoreService } from './data-store.service';
@ -21,15 +21,16 @@ export class OpenSlidesService extends OpenSlidesComponent {
public redirectUrl: string; public redirectUrl: string;
/** /**
* Constructor to create the NotifyService. Registers itself to the WebsocketService. * Constructor to create the OpenSlidesService. Registers itself to the WebsocketService.
* @param cacheService * @param storageService
* @param operator * @param operator
* @param websocketService * @param websocketService
* @param router * @param router
* @param autoupdateService * @param autoupdateService
* @param DS
*/ */
public constructor( public constructor(
private cacheService: CacheService, private storageService: StorageService,
private operator: OperatorService, private operator: OperatorService,
private websocketService: WebsocketService, private websocketService: WebsocketService,
private router: Router, private router: Router,
@ -51,54 +52,51 @@ export class OpenSlidesService extends OpenSlidesComponent {
* the bootup-sequence: Do a whoami request and if it was successful, do * the bootup-sequence: Do a whoami request and if it was successful, do
* {@method afterLoginBootup}. If not, redirect the user to the login page. * {@method afterLoginBootup}. If not, redirect the user to the login page.
*/ */
public bootup(): void { public async bootup(): Promise<void> {
// start autoupdate if the user is logged in: // start autoupdate if the user is logged in:
this.operator.whoAmI().subscribe(resp => { const response = await this.operator.whoAmI();
this.operator.guestsEnabled = resp.guest_enabled; this.operator.guestsEnabled = response.guest_enabled;
if (!resp.user && !resp.guest_enabled) { if (!response.user && !response.guest_enabled) {
this.redirectUrl = location.pathname; this.redirectUrl = location.pathname;
// Goto login, if the user isn't login and guests are not allowed // Goto login, if the user isn't login and guests are not allowed
this.router.navigate(['/login']); this.router.navigate(['/login']);
} else { } else {
this.afterLoginBootup(resp.user_id); await this.afterLoginBootup(response.user_id);
} }
});
} }
/** /**
* the login bootup-sequence: Check (and maybe clear) the cache und setup the DataStore * the login bootup-sequence: Check (and maybe clear) the cache und setup the DataStore
* and websocket. * and websocket. This "login" also may be the "login" of an anonymous when he is using
* OpenSlides as a guest.
* @param userId * @param userId
*/ */
public afterLoginBootup(userId: number): void { public async afterLoginBootup(userId: number): Promise<void> {
// Else, check, which user was logged in last time // Else, check, which user was logged in last time
this.cacheService.get<number>('lastUserLoggedIn').subscribe((id: number) => { const lastUserId = await this.storageService.get<number>('lastUserLoggedIn');
// if the user id changed, reset the cache. console.log('user transition:', lastUserId, '->', userId);
if (userId !== id) { // if the user changed, reset the cache and save the new user.
this.DS.clear((value: boolean) => { if (userId !== lastUserId) {
this.setupDataStoreAndWebSocket(); await this.DS.clear();
}); await this.storageService.set('lastUserLoggedIn', userId);
this.cacheService.set('lastUserLoggedIn', userId);
} else {
this.setupDataStoreAndWebSocket();
} }
}); await this.setupDataStoreAndWebSocket();
} }
/** /**
* Init DS from cache and after this start the websocket service. * Init DS from cache and after this start the websocket service.
*/ */
private setupDataStoreAndWebSocket(): void { private async setupDataStoreAndWebSocket(): Promise<void> {
this.DS.initFromCache().then((changeId: number) => { let changeId = await this.DS.initFromStorage();
this.websocketService.connect( console.log('change ID on DS setup', changeId);
false, if (changeId > 0) {
changeId changeId += 1;
); }
}); this.websocketService.connect({ changeId: changeId }); // Request changes after changeId.
} }
/** /**
* SHuts down OpenSlides. The websocket is closed and the operator is not set. * Shuts OpenSlides down. The websocket is closed and the operator is not set.
*/ */
public shutdown(): void { public shutdown(): void {
this.websocketService.close(); this.websocketService.close();
@ -108,32 +106,31 @@ export class OpenSlidesService extends OpenSlidesComponent {
/** /**
* Shutdown and bootup. * Shutdown and bootup.
*/ */
public reboot(): void { public async reboot(): Promise<void> {
this.shutdown(); this.shutdown();
this.bootup(); await this.bootup();
} }
/** /**
* Verify that the operator is the same as it was before a reconnect. * Verify that the operator is the same as it was before. Should be alled on a reconnect.
*/ */
private checkOperator(): void { private async checkOperator(): Promise<void> {
this.operator.whoAmI().subscribe(resp => { const response = await this.operator.whoAmI();
// User logged off. // User logged off.
if (!resp.user && !resp.guest_enabled) { if (!response.user && !response.guest_enabled) {
this.shutdown(); this.shutdown();
this.router.navigate(['/login']); this.router.navigate(['/login']);
} else { } else {
if ( if (
(this.operator.user && this.operator.user.id !== resp.user_id) || (this.operator.user && this.operator.user.id !== response.user_id) ||
(!this.operator.user && resp.user_id) (!this.operator.user && response.user_id)
) { ) {
// user changed // user changed
this.reboot(); await this.reboot();
} else { } else {
// User is still the same, but check for missed autoupdates. // User is still the same, but check for missed autoupdates.
this.autoupdateService.requestChanges(); this.autoupdateService.requestChanges();
} }
} }
});
} }
} }

View File

@ -1,12 +1,12 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { Observable, BehaviorSubject } from 'rxjs'; import { Observable, BehaviorSubject } from 'rxjs';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { tap, catchError } from 'rxjs/operators';
import { OpenSlidesComponent } from 'app/openslides.component'; import { OpenSlidesComponent } from 'app/openslides.component';
import { Group } from 'app/shared/models/users/group'; import { Group } from 'app/shared/models/users/group';
import { User } from '../../shared/models/users/user'; import { User } from '../../shared/models/users/user';
import { environment } from 'environments/environment'; import { environment } from 'environments/environment';
import { DataStoreService } from './data-store.service'; import { DataStoreService } from './data-store.service';
import { OfflineService } from './offline.service';
/** /**
* Permissions on the client are just strings. This makes clear, that * Permissions on the client are just strings. This makes clear, that
@ -17,7 +17,7 @@ export type Permission = string;
/** /**
* Response format of the WHoAMI request. * Response format of the WHoAMI request.
*/ */
interface WhoAmIResponse { export interface WhoAmIResponse {
user_id: number; user_id: number;
guest_enabled: boolean; guest_enabled: boolean;
user: User; user: User;
@ -77,9 +77,14 @@ export class OperatorService extends OpenSlidesComponent {
private operatorSubject: BehaviorSubject<User> = new BehaviorSubject<User>(null); private operatorSubject: BehaviorSubject<User> = new BehaviorSubject<User>(null);
/** /**
* Sets up an observer for watching changes in the DS. If the operator user or groups are changed,
* the operator's permissions are updated.
*
* @param http HttpClient * @param http HttpClient
* @param DS
* @param offlineService
*/ */
public constructor(private http: HttpClient, private DS: DataStoreService) { public constructor(private http: HttpClient, private DS: DataStoreService, private offlineService: OfflineService) {
super(); super();
this.DS.changeObservable.subscribe(newModel => { this.DS.changeObservable.subscribe(newModel => {
@ -101,16 +106,21 @@ export class OperatorService extends OpenSlidesComponent {
/** /**
* Calls `/apps/users/whoami` to find out the real operator. * Calls `/apps/users/whoami` to find out the real operator.
* @returns The response of the WhoAmI request.
*/ */
public whoAmI(): Observable<WhoAmIResponse> { public async whoAmI(): Promise<WhoAmIResponse> {
return this.http.get<WhoAmIResponse>(environment.urlPrefix + '/users/whoami/').pipe( try {
tap((response: WhoAmIResponse) => { const response = await this.http.get<WhoAmIResponse>(environment.urlPrefix + '/users/whoami/').toPromise();
if (response && response.user_id) { if (response && response.user) {
this.user = new User(response.user); this.user = new User(response.user);
} }
}), return response;
catchError(this.handleError()) } catch (e) {
) as Observable<WhoAmIResponse>; // TODO: Implement the offline service. Currently a guest-whoami response is returned and
// the DS cleared.
this.offlineService.goOfflineBecauseFailedWhoAmI();
return this.offlineService.getLastWhoAmI();
}
} }
/** /**

View File

@ -0,0 +1,15 @@
import { TestBed, inject } from '@angular/core/testing';
import { StorageService } from './storage.service';
describe('StorageService', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [StorageService]
});
});
it('should be created', inject([StorageService], (service: StorageService) => {
expect(service).toBeTruthy();
}));
});

View File

@ -0,0 +1,61 @@
import { Injectable } from '@angular/core';
import { LocalStorage } from '@ngx-pwa/local-storage';
/**
* Provides an async API to an key-value store using ngx-pwa which is internally
* using IndexedDB or localStorage as a fallback.
*/
@Injectable({
providedIn: 'root'
})
export class StorageService {
/**
* Constructor to create the StorageService. Needs the localStorage service.
* @param localStorage
*/
public constructor(private localStorage: LocalStorage) {}
/**
* Sets the item into the store asynchronously.
* @param key
* @param item
*/
public async set(key: string, item: any): Promise<void> {
if (item === null || item === undefined) {
await this.remove(key); // You cannot do a setItem with null or undefined...
} else {
if (!(await this.localStorage.setItem(key, item).toPromise())) {
throw new Error('Could not set the item.');
}
}
}
/**
* get a value from the store. You need to subscribe to the request to retrieve the value.
* @param key The key to get the value from
* @returns The requested value to the key
*/
public async get<T>(key: string): Promise<T> {
return await this.localStorage.getItem<T>(key).toPromise();
}
/**
* Remove the key from the store.
* @param key The key to remove the value from
*/
public async remove(key: string): Promise<void> {
if (!(await this.localStorage.removeItem(key).toPromise())) {
throw new Error('Could not delete the item.');
}
}
/**
* Clear the whole cache
*/
public async clear(): Promise<void> {
console.log('clear storage');
if (!(await this.localStorage.clear().toPromise())) {
throw new Error('Could not clear the storage.');
}
}
}

View File

@ -69,9 +69,17 @@ export class WebsocketService {
*/ */
private subjects: { [type: string]: Subject<any> } = {}; private subjects: { [type: string]: Subject<any> } = {};
/**
* Saves, if the service is in retry mode to get a connection to a previos connection lost.
*/
private retry = false;
/** /**
* Constructor that handles the router * Constructor that handles the router
* @param router the URL Router * @param router the URL Router
* @param matSnackBar
* @param zone
* @param translate
*/ */
public constructor( public constructor(
private router: Router, private router: Router,
@ -85,34 +93,46 @@ export class WebsocketService {
* *
* Uses NgZone to let all callbacks run in the angular context. * Uses NgZone to let all callbacks run in the angular context.
*/ */
public connect(retry: boolean = false, changeId?: number): void { public connect(
options: {
changeId?: number;
enableAutoupdates?: boolean;
} = {}
): void {
if (this.websocket) { if (this.websocket) {
return; this.close();
} }
// set defaults
options = Object.assign(options, {
enableAutoupdates: true
});
const queryParams: QueryParams = { const queryParams: QueryParams = {
'change_id': 0, autoupdate: options.enableAutoupdates
'autoupdate': true,
}; };
// comment-in if changes IDs are supported on server side.
/*if (changeId !== undefined) { if (options.changeId !== undefined) {
queryParams.changeId = changeId.toString(); queryParams.change_id = options.changeId;
}*/ }
// Create the websocket // Create the websocket
const socketProtocol = this.getWebSocketProtocol(); const socketProtocol = this.getWebSocketProtocol();
const socketServer = window.location.hostname + ':' + window.location.port; const socketServer = window.location.hostname + ':' + window.location.port;
const socketPath = this.getWebSocketPath(queryParams); const socketPath = this.getWebSocketPath(queryParams);
console.log('connect to', socketProtocol + socketServer + socketPath);
this.websocket = new WebSocket(socketProtocol + socketServer + socketPath); this.websocket = new WebSocket(socketProtocol + socketServer + socketPath);
// connection established. If this connect attept was a retry, // connection established. If this connect attept was a retry,
// The error notice will be removed and the reconnectSubject is published. // The error notice will be removed and the reconnectSubject is published.
this.websocket.onopen = (event: Event) => { this.websocket.onopen = (event: Event) => {
this.zone.run(() => { this.zone.run(() => {
if (retry) { if (this.retry) {
if (this.connectionErrorNotice) { if (this.connectionErrorNotice) {
this.connectionErrorNotice.dismiss(); this.connectionErrorNotice.dismiss();
this.connectionErrorNotice = null; this.connectionErrorNotice = null;
} }
this.retry = false;
this._reconnectEvent.emit(); this._reconnectEvent.emit();
} }
this._connectEvent.emit(); this._connectEvent.emit();
@ -151,7 +171,8 @@ export class WebsocketService {
// A random retry timeout between 2000 and 5000 ms. // A random retry timeout between 2000 and 5000 ms.
const timeout = Math.floor(Math.random() * 3000 + 2000); const timeout = Math.floor(Math.random() * 3000 + 2000);
setTimeout(() => { setTimeout(() => {
this.connect((retry = true)); this.retry = true;
this.connect({ enableAutoupdates: true });
}, timeout); }, timeout);
} }
}); });
@ -222,7 +243,9 @@ export class WebsocketService {
const keys: string[] = Object.keys(queryParams); const keys: string[] = Object.keys(queryParams);
if (keys.length > 0) { if (keys.length > 0) {
path += '?' + keys path +=
'?' +
keys
.map(key => { .map(key => {
return key + '=' + queryParams[key].toString(); return key + '=' + queryParams[key].toString();
}) })

View File

@ -127,26 +127,27 @@ export class LoginMaskComponent extends BaseComponent implements OnInit, OnDestr
* *
* Send username and password to the {@link AuthService} * Send username and password to the {@link AuthService}
*/ */
public formLogin(): void { public async formLogin(): Promise<void> {
this.loginErrorMsg = ''; this.loginErrorMsg = '';
this.inProcess = true; this.inProcess = true;
this.authService.login(this.loginForm.value.username, this.loginForm.value.password).subscribe(res => { try {
const res = await this.authService.login(this.loginForm.value.username, this.loginForm.value.password);
this.inProcess = false; this.inProcess = false;
if (res instanceof HttpErrorResponse) {
this.loginForm.setErrors({
notFound: true
});
this.loginErrorMsg = res.error.detail;
} else {
this.OpenSlides.afterLoginBootup(res.user_id); this.OpenSlides.afterLoginBootup(res.user_id);
let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/'; let redirect = this.OpenSlides.redirectUrl ? this.OpenSlides.redirectUrl : '/';
if (redirect.includes('login')) { if (redirect.includes('login')) {
redirect = '/'; redirect = '/';
} }
this.router.navigate([redirect]); this.router.navigate([redirect]);
} } catch (e) {
if (e instanceof HttpErrorResponse) {
this.loginForm.setErrors({
notFound: true
}); });
this.loginErrorMsg = e.error.detail;
this.inProcess = false;
}
}
} }
/** /**