diff --git a/client/src/app/core/repositories/config/config-repository.service.ts b/client/src/app/core/repositories/config/config-repository.service.ts index bc07e2f04..cc9aac81c 100644 --- a/client/src/app/core/repositories/config/config-repository.service.ts +++ b/client/src/app/core/repositories/config/config-repository.service.ts @@ -2,9 +2,9 @@ import { Injectable } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; import { CollectionStringMapperService } from 'app/core/core-services/collection-string-mapper.service'; -import { ConstantsService } from 'app/core/core-services/constants.service'; import { DataSendService } from 'app/core/core-services/data-send.service'; import { DataStoreService } from 'app/core/core-services/data-store.service'; import { HttpService } from 'app/core/core-services/http.service'; @@ -13,29 +13,9 @@ import { ViewModelStoreService } from 'app/core/core-services/view-model-store.s import { BaseRepository } from 'app/core/repositories/base-repository'; import { Identifiable } from 'app/shared/models/base/identifiable'; import { Config } from 'app/shared/models/core/config'; +import { ConfigItem } from 'app/site/config/components/config-list/config-list.component'; import { ConfigTitleInformation, ViewConfig } from 'app/site/config/models/view-config'; -/** - * Holds a single config item. - */ -interface ConfigItem { - /** - * The key of this config variable. - */ - key: string; - - /** - * The actual view config for this variable. - */ - config: ViewConfig; - - /** - * The config variable data given in the constants. This is hold here, so the view - * config can be updated with this data. - */ - data: any; -} - /** * Represents a config subgroup. It can only holds items and no further groups. */ @@ -48,7 +28,7 @@ interface ConfigSubgroup { /** * All items in this sub group. */ - items: ConfigItem[]; + configs: ViewConfig[]; } /** @@ -64,11 +44,6 @@ export interface ConfigGroup { * A list of subgroups. */ subgroups: ConfigSubgroup[]; - - /** - * A list of config items that are not in any subgroup. - */ - items: ConfigItem[]; } /** @@ -82,22 +57,27 @@ export class ConfigRepositoryService extends BaseRepository = new BehaviorSubject(null); + private readonly configsSubject: BehaviorSubject = new BehaviorSubject(null); /** - * Saves, if we got config variables (the structure) from the server. + * Custom observer for the config */ - protected gotConfigsVariables = false; + public get configsObservable(): Observable { + return this.configsSubject.asObservable(); + } /** - * Saves, if we got first configs via autoupdate or cache. + * Gets an observalble for all existing (main) config groups. Just the group names + * are given with this observable. */ - protected gotFirstUpdate = false; + public get availableGroupsOberservable(): Observable { + return this.configsSubject.pipe(map((groups: ConfigGroup[]) => groups.map(group => group.name))); + } /** * Constructor for ConfigRepositoryService. Requests the constants from the server and creates the config group structure. @@ -114,18 +94,15 @@ export class ConfigRepositoryService extends BaseRepository { - this.createConfigStructure(constant); - this.updateConfigStructure(false, ...Object.values(this.viewModelStore)); - this.gotConfigsVariables = true; - this.checkConfigStructure(); - this.updateConfigListObservable(); - }); + this.setSortFunction((a, b) => a.weight - b.weight); + + this.getViewModelListObservable().subscribe(configs => + this.updateConfigStructure(configs.filter(config => !config.hidden)) + ); } public getVerboseName = (plural: boolean = false) => { @@ -154,96 +131,50 @@ export class ConfigRepositoryService extends BaseRepository { - this.updateConfigStructure(false, this.viewModelStore[id]); - }); - this.gotFirstUpdate = true; - this.checkConfigStructure(); - this.updateConfigListObservable(); - } - - /** - * Custom observer for the config - */ - public getConfigListObservable(): Observable { - return this.configListSubject.asObservable(); - } - /** * Custom notification for the observers. */ protected updateConfigListObservable(): void { if (this.configs) { - this.configListSubject.next(this.configs); + this.configsSubject.next(this.configs); } } - /** - * Getter for the config structure - */ - public getConfigStructure(): ConfigGroup[] { - return this.configs; + public getConfigGroupOberservable(name: string): Observable { + return this.configsSubject.pipe( + map((groups: ConfigGroup[]) => groups.find(group => group.name.toLowerCase() === name)) + ); } - /** - * Checks the config structure, if we got configs (first data) and the - * structure (config variables) - */ - protected checkConfigStructure(): void { - if (this.gotConfigsVariables && this.gotFirstUpdate) { - this.updateConfigStructure(true, ...Object.values(this.viewModelStore)); - } - } + protected updateConfigStructure(configs: ViewConfig[]): void { + const groups: ConfigGroup[] = []; - /** - * With a given (and maybe partially filled) config structure, all given view configs are put into it. - * @param check Whether to check, if all given configs are there (according to the config structure). - * If check is true and one viewConfig is missing, the user will get an error message. - * @param viewConfigs All view configs to put into the structure - */ - protected updateConfigStructure(check: boolean, ...viewConfigs: ViewConfig[]): void { - if (!this.configs) { - return; - } + configs.forEach(config => { + if (groups.length === 0 || groups[groups.length - 1].name !== config.group) { + groups.push({ + name: config.group, + subgroups: [] + }); + } - // Map the viewConfigs to their keys. - const keyConfigMap: { [key: string]: ViewConfig } = {}; - viewConfigs.forEach(viewConfig => { - keyConfigMap[viewConfig.key] = viewConfig; + const subgroupsLength = groups[groups.length - 1].subgroups.length; + if ( + subgroupsLength === 0 || + groups[groups.length - 1].subgroups[subgroupsLength - 1].name !== config.subgroup + ) { + groups[groups.length - 1].subgroups.push({ + name: config.subgroup, + configs: [] + }); + } + groups[groups.length - 1].subgroups[groups[groups.length - 1].subgroups.length - 1].configs.push(config); }); - // traverse through configs structure and replace all given viewConfigs - for (const group of this.configs) { - for (const subgroup of group.subgroups) { - for (const item of subgroup.items) { - if (keyConfigMap[item.key]) { - keyConfigMap[item.key].setConstantsInfo(item.data); - item.config = keyConfigMap[item.key]; - } else if (check) { - throw new Error( - `No config variable found for "${item.key}". Please migrate the database or rebuild the servercache.` - ); - } - } - } - for (const item of group.items) { - if (keyConfigMap[item.key]) { - keyConfigMap[item.key].setConstantsInfo(item.data); - item.config = keyConfigMap[item.key]; - } else if (check) { - throw new Error( - `No config variable found for "${item.key}". Please migrate the database or rebuild the servercache.` - ); - } - } - } + this.configsSubject.next(groups); } /** - * Saves a config value. + * Saves a config value. The server needs the key instead of the id to fetch the config variable. */ public async update(config: Partial, viewConfig: ViewConfig): Promise { const updatedConfig = viewConfig.getUpdatedModel(config); @@ -251,43 +182,24 @@ export class ConfigRepositoryService extends BaseRepository { + return await this.http.post(`/rest/core/config/bulk_update/`, configItems); + } + + /** + * Function to send a `reset`-poll for every group to the server. + * + * @param groups The names of the groups, that should be updated. + * + * @returns The answer of the server. + */ + public async resetGroups(groups: string[]): Promise { + return await this.http.post(`/rest/core/config/reset_groups/`, groups); } } diff --git a/client/src/app/shared/components/block-tile/block-tile.component.html b/client/src/app/shared/components/block-tile/block-tile.component.html index 25a4799b7..ff8e6a4a1 100644 --- a/client/src/app/shared/components/block-tile/block-tile.component.html +++ b/client/src/app/shared/components/block-tile/block-tile.component.html @@ -1,57 +1,65 @@ +
-
-
- - - - - - -
- {{ block }} -
-
-
- -
-
- +
+ + {{ block }} + + + +
-
+
- - {{ title }} - - - {{ subtitle }} - - -
- -
- - + + + + {{ title }} + + + {{ subtitle }} + + + +
+ +
+
+ +
- + - + - + diff --git a/client/src/app/shared/components/block-tile/block-tile.component.scss b/client/src/app/shared/components/block-tile/block-tile.component.scss index 24709c632..51d7902f9 100644 --- a/client/src/app/shared/components/block-tile/block-tile.component.scss +++ b/client/src/app/shared/components/block-tile/block-tile.component.scss @@ -3,11 +3,34 @@ .block-tile { padding: 0; + &.is-square:not(.vertical) { + .block-node-container { + padding-bottom: 100%; + } + } + + &.vertical { + &.is-square { + padding-bottom: 100%; + height: 0; + } + + .block-node-container { + padding-bottom: 50%; + } + .tile-content-node-container { + margin: 0; + } + } + .block-node-container { position: relative; - padding-bottom: 50%; min-width: 30%; + &:not(.no-padding) { + padding-bottom: 50%; + } + .tile-text { table { height: 100%; @@ -20,9 +43,8 @@ } .tile-content-node-container { - position: relative; width: 100%; - margin: 8px 16px !important; + margin: 8px 16px; .tile-content { margin-bottom: 0; @@ -47,7 +69,7 @@ } } - .tile-content-extra { + .tile-content-extra:not(.only-content) { padding-top: 8px; } } diff --git a/client/src/app/shared/components/block-tile/block-tile.component.ts b/client/src/app/shared/components/block-tile/block-tile.component.ts index 7650577c8..b20143967 100644 --- a/client/src/app/shared/components/block-tile/block-tile.component.ts +++ b/client/src/app/shared/components/block-tile/block-tile.component.ts @@ -1,39 +1,24 @@ -import { Component, ContentChild, Input, TemplateRef } from '@angular/core'; +import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core'; import { TileComponent } from '../tile/tile.component'; -/** - * Enumeration to define if the content is only text or a node. - */ -export enum ContentType { - text = 'text', - node = 'node' -} - /** * Enumeration to define of which the big block is. */ export enum BlockType { text = 'text', - node = 'node', - picture = 'picture' + node = 'node' } /** * Tells, whether to align the block and content next to each other or one below the other. */ -export enum Orientation { - horizontal = 'horizontal', - vertical = 'vertical' -} +export type Orientation = 'horizontal' | 'vertical'; /** * Tells, if the tile should only display the content or the title in the content part. */ -export enum ShowOnly { - title = 'title', - content = 'content' -} +export type ShowOnly = 'title' | 'content' | null; /** * Class, that extends the `tile.component`. @@ -45,30 +30,42 @@ export enum ShowOnly { templateUrl: './block-tile.component.html', styleUrls: ['./block-tile.component.scss'] }) -export class BlockTileComponent extends TileComponent { +export class BlockTileComponent extends TileComponent implements AfterViewInit { /** * Reference to the content of the content part. */ - @ContentChild(TemplateRef, { static: true }) - public contentNode: TemplateRef; + @ViewChild('contentNode', { static: false }) + public contentNode: ElementRef; /** * Reference to the block part, if it is a node. */ - @ContentChild(TemplateRef, { static: true }) - public blockNode: TemplateRef; + @ViewChild('blockNode', { static: false }) + public blockNode: ElementRef; /** * Reference to the action buttons in the content part, if used. */ - @ContentChild(TemplateRef, { static: true }) - public actionNode: TemplateRef; + @ViewChild('actionNode', { static: false }) + public actionNode: ElementRef; /** * Defines the type of the primary block. */ @Input() - public blockType: BlockType; + public blockType: BlockType = BlockType.node; + + /** + * Manually remove the padding, the block is surrounded by. + */ + @Input() + public noPaddingBlock = false; + + /** + * Renders the block-tile as a square. + */ + @Input() + public isSquare = false; /** * Input for the primary block content. @@ -77,12 +74,6 @@ export class BlockTileComponent extends TileComponent { @Input() public block: string; - /** - * Defines the type of the content. - */ - @Input() - public contentType: ContentType; - /** * The title in the content part. */ @@ -100,17 +91,130 @@ export class BlockTileComponent extends TileComponent { * whether the block part should be displayed above the content or next to it. */ @Input() - public orientation: Orientation; + public orientation: Orientation = 'horizontal'; /** * Tells, whether the tile should display only one of `Title` or `Content` in the content part. */ @Input() - public only: ShowOnly; + public only: ShowOnly = null; + + /** + * Boolean, if the block-part of the tile is shown or not. + */ + private _showBlockNode: boolean; + + /** + * To decide, whether the block-node should always be shown. + * Otherwise this will decide automatically. + * + * @param show Whether the block-part should be shown or not. + */ + @Input() + public set showBlockNode(show: boolean) { + this._showBlockNode = show; + } + + /** + * @returns A boolean whether the block-part of the tile should be shown. + * If this is not set manually, it will return `true` for the first time to check, + * if this part contains any nodes. + */ + public get showBlockNode(): boolean { + return typeof this._showBlockNode === 'undefined' ? true : this._showBlockNode; + } + + /** + * Boolean, if the content-part of the tile is shown or not. + */ + private _showContentNode: boolean; + + /** + * To decide, whether the content-node should always be shown. + * Otherwise this will decide automatically. + * + * @param show Whether the content-part should be shown or not. + */ + @Input() + public set showContentNode(show: boolean) { + this._showContentNode = show; + } + + /** + * @returns A boolean whether the content-part of the tile should be shown. + * If this is not set manually, it will return `true` for the first time to check, + * if this part contains any nodes. + */ + public get showContentNode(): boolean { + return typeof this._showContentNode === 'undefined' + ? true + : this._showContentNode || !!this.only || !!this.title; + } + + /** + * Boolean, if the part with actions of the tile is shown or not. + */ + private _showActionNode: boolean; /** * Boolean, whether to show action buttons in the content part. + * + * @param show Whether the action-part should be shown or not. */ @Input() - public showActions: boolean; + public set showActions(show: boolean) { + this._showActionNode = show; + } + + /** + * @returns A boolean whether the action-part of the tile should be shown. + * If this is not set manually, it will return `true` for the first time to check, + * if this part contains any nodes. + */ + public get showActions(): boolean { + return typeof this._showActionNode === 'undefined' ? true : this._showActionNode; + } + + /** + * Default constructor. + * + * @param cd ChangeDetectorRef + */ + public constructor(private cd: ChangeDetectorRef) { + super(); + } + + /** + * AfterViewInit. + * + * Here it will check, if the visibility of the three parts of the tile is set manually. + * If not, it will check, if the parts contain nodes to display or not. + */ + public ngAfterViewInit(): void { + if (typeof this._showBlockNode === 'undefined') { + this.showBlockNode = this.checkForContent(this.blockNode); + } + if (typeof this._showContentNode === 'undefined') { + this.showContentNode = this.checkForContent(this.contentNode); + } + if (typeof this._showActionNode === 'undefined') { + this.showActions = this.checkForContent(this.actionNode); + } + this.cd.detectChanges(); + } + + /** + * Function to test, whether the child-nodes of the given parent-element + * are a comment or not. If not, then the parent-element contains content to display. + * + * @param parentElement The element whose child-nodes are tested. + * + * @returns `True`, if there is at least one node other than a comment. + */ + private checkForContent(parentElement: ElementRef): boolean { + if (!parentElement) { + return false; + } + return Array.from(parentElement.nativeElement.childNodes).some(item => item.nodeType !== 8); + } } diff --git a/client/src/app/shared/components/head-bar/head-bar.component.html b/client/src/app/shared/components/head-bar/head-bar.component.html index 06c1fb630..14ddecfa2 100644 --- a/client/src/app/shared/components/head-bar/head-bar.component.html +++ b/client/src/app/shared/components/head-bar/head-bar.component.html @@ -29,7 +29,7 @@
- @@ -40,7 +40,8 @@ + + +
diff --git a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html index 474041028..39b69e895 100644 --- a/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html +++ b/client/src/app/site/assignments/components/assignment-detail/assignment-detail.component.html @@ -1,5 +1,5 @@ @@ -91,6 +91,12 @@ archive Export ... + + +
diff --git a/client/src/app/site/base/sort-tree.component.ts b/client/src/app/site/base/sort-tree.component.ts index d23546fd4..ad63627fb 100644 --- a/client/src/app/site/base/sort-tree.component.ts +++ b/client/src/app/site/base/sort-tree.component.ts @@ -8,7 +8,7 @@ import { SortDefinition } from 'app/core/ui-services/base-sort.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; import { Identifiable } from 'app/shared/models/base/identifiable'; -import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; +import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard'; import { BaseViewComponent } from './base-view'; import { BaseViewModel } from './base-view-model'; diff --git a/client/src/app/site/config/components/config-field/config-field.component.html b/client/src/app/site/config/components/config-field/config-field.component.html index e6399130c..5b35be24c 100644 --- a/client/src/app/site/config/components/config-field/config-field.component.html +++ b/client/src/app/site/config/components/config-field/config-field.component.html @@ -15,7 +15,7 @@ {{ configItem.label | translate }} - check_circle + error {{ error }} @@ -57,7 +57,7 @@ [value]="translatedValue" > - + check_circle @@ -77,7 +77,7 @@ /> {{ configItem.helpText | translate }}
- check_circle + check_circle
- check_circle + error
{{ error }} @@ -103,16 +103,13 @@

{{ configItem.label | translate }}

- check_circle + error
- - check_circle -
@@ -122,9 +119,4 @@ help_outline
-
- -
diff --git a/client/src/app/site/config/components/config-field/config-field.component.ts b/client/src/app/site/config/components/config-field/config-field.component.ts index 17f57e39f..77b23a6c3 100644 --- a/client/src/app/site/config/components/config-field/config-field.component.ts +++ b/client/src/app/site/config/components/config-field/config-field.component.ts @@ -2,9 +2,11 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + EventEmitter, Input, OnDestroy, OnInit, + Output, ViewEncapsulation } from '@angular/core'; import { FormBuilder, FormGroup } from '@angular/forms'; @@ -18,6 +20,7 @@ import { distinctUntilChanged } from 'rxjs/operators'; import { BaseComponent } from 'app/base.component'; import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service'; import { ParentErrorStateMatcher } from 'app/shared/parent-error-state-matcher'; +import { ConfigItem } from '../config-list/config-list.component'; import { ViewConfig } from '../../models/view-config'; /** @@ -26,7 +29,7 @@ import { ViewConfig } from '../../models/view-config'; * * @example * ```ts - * + * * ``` */ @Component({ @@ -44,16 +47,6 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes */ public updateSuccessIcon = false; - /** - * The timeout for the success icon to hide. - */ - private updateSuccessIconTimeout: number | null = null; - - /** - * The debounce timeout for inputs request delay. - */ - private debounceTimeout: number | null = null; - /** * A possible error send by the server. */ @@ -69,8 +62,8 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes * populated constants-info. */ @Input() - public set item(value: ViewConfig) { - if (value && value.hasConstantsInfo) { + public set config(value: ViewConfig) { + if (value) { this.configItem = value; if (this.form) { @@ -90,6 +83,25 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes } } + /** + * Passes the list of errors as object. + * + * The function looks, if the key of this config-item is contained in the list. + * + * @param errorList The object containing all errors. + */ + @Input() + public set errorList(errorList: { [key: string]: any }) { + const hasError = Object.keys(errorList).find(errorKey => errorKey === this.configItem.key); + if (hasError) { + this.error = errorList[hasError]; + this.updateError(true); + } else { + this.error = null; + this.updateError(null); + } + } + /** * The form for this configItem. */ @@ -100,6 +112,9 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes */ public matcher = new ParentErrorStateMatcher(); + @Output() + public update = new EventEmitter(); + /** * The usual component constructor. datetime pickers will set their locale * to the current language chosen @@ -172,7 +187,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes private unixToDateAndTime(unix: number): { date: Moment; time: string } { const date = moment.unix(unix); const time = date.hours() + ':' + date.minutes(); - return { date: date, time: time }; + return { date, time }; } /** @@ -211,12 +226,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes const time = this.form.get('time').value; value = this.dateAndTimeToUnix(date, time); } - if (this.debounceTimeout !== null) { - clearTimeout(this.debounceTimeout); - } - this.debounceTimeout = setTimeout(() => { - this.update(value); - }, this.configItem.getDebouncingTimeout()); + this.sendUpdate(value); this.cd.detectChanges(); } @@ -233,53 +243,22 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes * Sends an update request for the config item to the server. * @param value The new value to set. */ - private update(value: any): void { - this.debounceTimeout = null; - this.repo.update({ value: value }, this.configItem).then(() => { - this.error = null; - this.showSuccessIcon(); - }, this.setError.bind(this)); + private sendUpdate(value: any): void { + this.update.emit({ key: this.configItem.key, value }); } /** - * Show the green success icon on the component. The icon gets automatically cleared. + * Function to update the form-control to display or hide an error. + * + * @param error `true | false`, if an error should be shown. `null`, if there is no error. */ - private showSuccessIcon(): void { - if (this.updateSuccessIconTimeout !== null) { - clearTimeout(this.updateSuccessIconTimeout); - } - this.updateSuccessIconTimeout = setTimeout(() => { - this.updateSuccessIcon = false; - if (!this.wasViewDestroyed()) { - this.cd.detectChanges(); - } - }, 2000); - this.updateSuccessIcon = true; - if (!this.wasViewDestroyed()) { + private updateError(error: boolean | null): void { + if (this.form) { + this.form.setErrors(error ? { error } : null); this.cd.detectChanges(); } } - /** - * @returns true, if the view was destroyed. Note: This - * needs to access internal attributes from the change detection - * reference. - */ - private wasViewDestroyed(): boolean { - return (this.cd).destroyed; - } - - /** - * Sets the error on this field. - * - * @param error The error as string. - */ - private setError(error: string): void { - this.error = error; - this.form.setErrors({ error: true }); - this.cd.detectChanges(); - } - /** * Uses the configItem to determine the kind of interation: * input, textarea, choice or date @@ -311,16 +290,6 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes return excluded.includes(type); } - /** - * Determines if a reset buton should be offered. - * TODO: is 'null' a valid default in some cases? - * - * @returns true if any default exists - */ - public hasDefault(): boolean { - return this.configItem.defaultValue !== undefined && this.configItem.defaultValue !== null; - } - /** * Amends the application-wide tinyMCE settings with update triggers that * send updated values only after leaving focus (Blur) or closing the editor (Remove) @@ -333,7 +302,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes setup: editor => { editor.on('Blur', ev => { if (ev.target.getContent() !== this.translatedValue) { - this.update(ev.target.getContent()); + this.sendUpdate(ev.target.getContent()); } }); editor.on('Remove', ev => { @@ -341,7 +310,7 @@ export class ConfigFieldComponent extends BaseComponent implements OnInit, OnDes // fast navigation, when the editor is not fully loaded. Then the content is empty // and would trigger an update with empty data. if (ev.target.getContent() && ev.target.getContent() !== this.translatedValue) { - this.update(ev.target.getContent()); + this.sendUpdate(ev.target.getContent()); } }); } diff --git a/client/src/app/site/config/components/config-list/config-list.component.html b/client/src/app/site/config/components/config-list/config-list.component.html index cead7cbe7..a45da2f29 100644 --- a/client/src/app/site/config/components/config-list/config-list.component.html +++ b/client/src/app/site/config/components/config-list/config-list.component.html @@ -1,28 +1,45 @@ - +
-

Settings

+

+ {{ configGroup.name | translate }} +

+
+ + +
- - - - - - {{ group.name | translate }} - - -
-

{{ subgroup.name | translate }}

-
- -
-
-
- -
-
-
-
+ + +
+ +

{{ subgroup.name | translate }}

+ + + +
+
+
+ + + + diff --git a/client/src/app/site/config/components/config-list/config-list.component.scss b/client/src/app/site/config/components/config-list/config-list.component.scss index adf71ecc7..6bdb2f52e 100644 --- a/client/src/app/site/config/components/config-list/config-list.component.scss +++ b/client/src/app/site/config/components/config-list/config-list.component.scss @@ -1,8 +1,12 @@ -mat-expansion-panel { - max-width: 770px; - margin: auto; +#wrapper { + font-size: 14px; } h3.accent { - margin-top: 30px; margin-bottom: 20px; + &:first-child { + margin-top: 0; + } + &:not(:first-child) { + margin-top: 30px; + } } diff --git a/client/src/app/site/config/components/config-list/config-list.component.ts b/client/src/app/site/config/components/config-list/config-list.component.ts index d60575940..7e4af75b3 100644 --- a/client/src/app/site/config/components/config-list/config-list.component.ts +++ b/client/src/app/site/config/components/config-list/config-list.component.ts @@ -1,10 +1,29 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; +import { ActivatedRoute } from '@angular/router'; import { TranslateService } from '@ngx-translate/core'; +import { Subscription } from 'rxjs'; import { BaseComponent } from 'app/base.component'; import { ConfigGroup, ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; +import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard'; + +/** + * Key-value-pair to set a setting with its associated value. + */ +export interface ConfigItem { + /** + * The key has to be a string. + */ + key: string; + + /** + * The value can be any. + */ + value: any; +} /** * List view for the global settings @@ -14,13 +33,27 @@ import { ConfigGroup, ConfigRepositoryService } from 'app/core/repositories/conf templateUrl: './config-list.component.html', styleUrls: ['./config-list.component.scss'] }) -export class ConfigListComponent extends BaseComponent implements OnInit { - public configs: ConfigGroup[]; +export class ConfigListComponent extends BaseComponent implements CanComponentDeactivate, OnInit, OnDestroy { + public configGroup: ConfigGroup; + + public configGroupSubscription: Subscription | null = null; + + /** + * Object containing all errors. + */ + public errors = {}; + + /** + * Array of all changed settings. + */ + private configItems: ConfigItem[] = []; public constructor( protected titleService: Title, protected translate: TranslateService, - private repo: ConfigRepositoryService + private repo: ConfigRepositoryService, + private route: ActivatedRoute, + private promptDialog: PromptService ) { super(titleService, translate); } @@ -29,10 +62,85 @@ export class ConfigListComponent extends BaseComponent implements OnInit { * Sets the title, inits the table and calls the repo */ public ngOnInit(): void { - super.setTitle('Settings'); - - this.repo.getConfigListObservable().subscribe(configs => { - this.configs = configs; + const settings = this.translate.instant('Settings'); + this.route.params.subscribe(params => { + this.clearSubscription(); + this.configGroupSubscription = this.repo.getConfigGroupOberservable(params.group).subscribe(configGroup => { + if (configGroup) { + const groupName = this.translate.instant(configGroup.name); + super.setTitle(`${settings} - ${groupName}`); + this.configGroup = configGroup; + } + }); }); } + + /** + * Updates the specified config-item indicated by the given key. + * + * @param key The key of the config-item. + * @param value The next value the config-item has. + */ + public updateConfigGroup(update: ConfigItem): void { + const { key, value }: ConfigItem = update; + const index = this.configItems.findIndex(item => item.key === key); + if (index === -1) { + this.configItems.push({ key, value }); + } else { + this.configItems[index] = { key, value }; + } + } + + /** + * Saves every field in this config-group. + */ + public saveAll(): void { + this.repo.bulkUpdate(this.configItems).then(result => { + this.errors = result.errors; + if (Object.keys(result.errors).length === 0) { + this.configItems = []; + } + }); + } + + /** + * This resets all values to their defaults. + */ + public resetAll(): void { + this.repo.resetGroups([this.configGroup.name]); + } + + /** + * Returns, if there are changes depending on the `configMap`. + * + * @returns True, if the array `configMap` has at least one member. + */ + public hasChanges(): boolean { + return !!this.configItems.length; + } + + private clearSubscription(): void { + if (this.configGroupSubscription) { + this.configGroupSubscription.unsubscribe(); + this.configGroupSubscription = null; + } + } + + public ngOnDestroy(): void { + this.clearSubscription(); + } + + /** + * Lifecycle-hook to hook into, before the route changes. + * + * @returns The answer of the user, if he made changes, `true` otherwise. + */ + public async canDeactivate(): Promise { + if (this.hasChanges()) { + const title = this.translate.instant('Do you really want to exit this page?'); + const content = this.translate.instant('You made changes.'); + return await this.promptDialog.open(title, content); + } + return true; + } } diff --git a/client/src/app/site/config/components/config-overview/config-overview.component.html b/client/src/app/site/config/components/config-overview/config-overview.component.html new file mode 100644 index 000000000..84e22a976 --- /dev/null +++ b/client/src/app/site/config/components/config-overview/config-overview.component.html @@ -0,0 +1,50 @@ + + +
+

Settings

+
+ + +
+ + + + + + + + + + +
+ home + today + assignment + how_to_vote + groups + language +
+
+ + + + +
+
+ + + + diff --git a/client/src/app/site/config/components/config-overview/config-overview.component.scss b/client/src/app/site/config/components/config-overview/config-overview.component.scss new file mode 100644 index 000000000..411daf554 --- /dev/null +++ b/client/src/app/site/config/components/config-overview/config-overview.component.scss @@ -0,0 +1,14 @@ +.parent-wrapper { + position: relative; + padding: 50%; + a { + text-decoration: none; + } +} + +.link-wrapper { + text-decoration: none; + padding: 10% 0; + display: block; + text-align: center; +} diff --git a/client/src/app/site/config/components/config-overview/config-overview.component.spec.ts b/client/src/app/site/config/components/config-overview/config-overview.component.spec.ts new file mode 100644 index 000000000..859b1100d --- /dev/null +++ b/client/src/app/site/config/components/config-overview/config-overview.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ConfigOverviewComponent } from './config-overview.component'; +import { E2EImportsModule } from '../../../../../e2e-imports.module'; + +describe('ConfigOverviewComponent', () => { + let component: ConfigOverviewComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + declarations: [ConfigOverviewComponent] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(ConfigOverviewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/config/components/config-overview/config-overview.component.ts b/client/src/app/site/config/components/config-overview/config-overview.component.ts new file mode 100644 index 000000000..2bc5a9e02 --- /dev/null +++ b/client/src/app/site/config/components/config-overview/config-overview.component.ts @@ -0,0 +1,50 @@ +import { Component, OnInit } from '@angular/core'; +import { Title } from '@angular/platform-browser'; + +import { TranslateService } from '@ngx-translate/core'; + +import { BaseComponent } from 'app/base.component'; +import { ConfigRepositoryService } from 'app/core/repositories/config/config-repository.service'; +import { PromptService } from 'app/core/ui-services/prompt.service'; + +/** + * List view for the global settings + */ +@Component({ + selector: 'os-config-overview', + templateUrl: './config-overview.component.html', + styleUrls: ['./config-overview.component.scss'] +}) +export class ConfigOverviewComponent extends BaseComponent implements OnInit { + public groups: string[] = []; + + public constructor( + protected titleService: Title, + protected translate: TranslateService, + public repo: ConfigRepositoryService, + private promptDialog: PromptService + ) { + super(titleService, translate); + } + + /** + * Sets the title, inits the table and calls the repo + */ + public ngOnInit(): void { + super.setTitle('Settings'); + + this.repo.availableGroupsOberservable.subscribe(groups => { + this.groups = groups; + }); + } + + /** + * Resets every config for all registered group. + */ + public async resetAll(): Promise { + const title = this.translate.instant('Are you sure, you want to reset all groups?'); + if (await this.promptDialog.open(title)) { + await this.repo.resetGroups(this.groups); + } + } +} diff --git a/client/src/app/site/config/components/custom-translation/custom-translation.component.html b/client/src/app/site/config/components/custom-translation/custom-translation.component.html index 031522523..70e771a35 100644 --- a/client/src/app/site/config/components/custom-translation/custom-translation.component.html +++ b/client/src/app/site/config/components/custom-translation/custom-translation.component.html @@ -1,18 +1,32 @@ - -
-
- - - - arrow_forward - - - - -
-
+
+
+ + + + You have to fill this field. + + arrow_forward + + + You have to fill this field. + + + +
+
- + diff --git a/client/src/app/site/config/components/custom-translation/custom-translation.component.scss b/client/src/app/site/config/components/custom-translation/custom-translation.component.scss index e69de29bb..ba530f0a7 100644 --- a/client/src/app/site/config/components/custom-translation/custom-translation.component.scss +++ b/client/src/app/site/config/components/custom-translation/custom-translation.component.scss @@ -0,0 +1,8 @@ +.inline-form { + display: flex; + align-items: center; + + mat-icon { + margin: 0 8px; + } +} diff --git a/client/src/app/site/config/components/custom-translation/custom-translation.component.ts b/client/src/app/site/config/components/custom-translation/custom-translation.component.ts index e1889e23d..869d42d69 100644 --- a/client/src/app/site/config/components/custom-translation/custom-translation.component.ts +++ b/client/src/app/site/config/components/custom-translation/custom-translation.component.ts @@ -1,7 +1,5 @@ -import { Component, forwardRef } from '@angular/core'; -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; - -import { CustomTranslation, CustomTranslations } from 'app/core/translate/translation-parser'; +import { Component, forwardRef, OnInit } from '@angular/core'; +import { ControlValueAccessor, FormArray, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; /** * Custom translations as custom form component @@ -23,16 +21,39 @@ import { CustomTranslation, CustomTranslations } from 'app/core/translate/transl } ] }) -export class CustomTranslationComponent implements ControlValueAccessor { +export class CustomTranslationComponent implements ControlValueAccessor, OnInit { /** - * Holds the custom translations in a list + * The parent form-group. */ - public translations: CustomTranslations = []; + public translationForm: FormGroup; /** - * Empty constructor + * Reference to the form-control within the `translationForm`. */ - public constructor() {} + public translationBoxes: FormArray; + + /** + * Default constructor. + * + * @param fb FormBuilder + */ + public constructor(private fb: FormBuilder) {} + + /** + * Initializes the form-controls. + */ + public ngOnInit(): void { + this.translationForm = this.fb.group({ + translationBoxes: this.fb.array([]) + }); + + this.translationBoxes = this.translationForm.get('translationBoxes') as FormArray; + this.translationBoxes.valueChanges.subscribe(value => { + if (this.translationBoxes.valid) { + this.propagateChange(value); + } + }); + } /** * Helper function to determine which information to give to the parent form @@ -46,7 +67,9 @@ export class CustomTranslationComponent implements ControlValueAccessor { */ public writeValue(obj: any): void { if (obj) { - this.translations = obj; + for (const item of obj) { + this.addNewTranslation(item.original, item.translation); + } } } @@ -73,46 +96,28 @@ export class CustomTranslationComponent implements ControlValueAccessor { */ public setDisabledState?(isDisabled: boolean): void {} - /** - * Detects changes to the "original" word - * - * @param value the value that was typed - * @param index the index of the change - */ - public onChangeOriginal(value: string, index: number): void { - this.translations[index].original = value; - this.propagateChange(this.translations); - } - - /** - * Detects changes to the translation - * @param value the value that was typed - * @param index the index of the change - */ - public onChangeTranslation(value: string, index: number): void { - this.translations[index].translation = value; - this.propagateChange(this.translations); - } - /** * Removes a custom translation + * * @param index the translation to remove */ public onRemoveTranslation(index: number): void { - this.translations.splice(index, 1); - this.propagateChange(this.translations); + this.translationBoxes.removeAt(index); } /** - * Adds a new custom translation to the list and to the server + * Function to add a new translation-field to the form-array. + * If strings are passed, they are passed as the fields' value. + * + * @param original The original string to translate. + * @param translation The translation for the given string. */ - public onAddNewTranslation(): void { - const newCustomTranslation: CustomTranslation = { - original: 'New', - translation: 'New' - }; - - this.translations.push(newCustomTranslation); - this.propagateChange(this.translations); + public addNewTranslation(original: string = '', translation: string = ''): void { + this.translationBoxes.push( + this.fb.group({ + original: [original, Validators.required], + translation: [translation, Validators.required] + }) + ); } } diff --git a/client/src/app/site/config/config-routing.module.ts b/client/src/app/site/config/config-routing.module.ts index 3ceb76e3a..1e6749485 100644 --- a/client/src/app/site/config/config-routing.module.ts +++ b/client/src/app/site/config/config-routing.module.ts @@ -1,9 +1,14 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; +import { WatchForChangesGuard } from 'app/shared/utils/watch-for-changes.guard'; import { ConfigListComponent } from './components/config-list/config-list.component'; +import { ConfigOverviewComponent } from './components/config-overview/config-overview.component'; -const routes: Routes = [{ path: '', component: ConfigListComponent, pathMatch: 'full' }]; +const routes: Routes = [ + { path: '', component: ConfigOverviewComponent, pathMatch: 'full' }, + { path: ':group', component: ConfigListComponent, canDeactivate: [WatchForChangesGuard] } +]; @NgModule({ imports: [RouterModule.forChild(routes)], diff --git a/client/src/app/site/config/config.module.ts b/client/src/app/site/config/config.module.ts index 07db66d4c..cc0b83019 100644 --- a/client/src/app/site/config/config.module.ts +++ b/client/src/app/site/config/config.module.ts @@ -3,13 +3,14 @@ import { NgModule } from '@angular/core'; import { ConfigFieldComponent } from './components/config-field/config-field.component'; import { ConfigListComponent } from './components/config-list/config-list.component'; +import { ConfigOverviewComponent } from './components/config-overview/config-overview.component'; import { ConfigRoutingModule } from './config-routing.module'; import { CustomTranslationComponent } from './components/custom-translation/custom-translation.component'; import { SharedModule } from '../../shared/shared.module'; @NgModule({ imports: [CommonModule, ConfigRoutingModule, SharedModule], - declarations: [ConfigListComponent, ConfigFieldComponent, CustomTranslationComponent], + declarations: [ConfigOverviewComponent, ConfigListComponent, ConfigFieldComponent, CustomTranslationComponent], entryComponents: [CustomTranslationComponent] }) export class ConfigModule {} diff --git a/client/src/app/site/config/models/view-config.ts b/client/src/app/site/config/models/view-config.ts index 5c206725d..576e7783a 100644 --- a/client/src/app/site/config/models/view-config.ts +++ b/client/src/app/site/config/models/view-config.ts @@ -1,37 +1,6 @@ -import { Config } from 'app/shared/models/core/config'; +import { Config, ConfigChoice, ConfigData, ConfigInputType } from 'app/shared/models/core/config'; import { BaseViewModel } from '../../base/base-view-model'; -interface ConfigChoice { - value: string; - displayName: string; -} - -/** - * All valid input types for config variables. - */ -type ConfigInputType = - | 'text' - | 'string' - | 'boolean' - | 'markupText' - | 'integer' - | 'choice' - | 'datetimepicker' - | 'colorpicker' - | 'translations'; - -/** - * Represents all information that is given in the constant. - */ -interface ConfigConstant { - default_value?: string; - help_text?: string; - input_type: ConfigInputType; - key: string; - label: string; - choices?: ConfigChoice[]; -} - export interface ConfigTitleInformation { key: string; } @@ -43,22 +12,6 @@ export class ViewConfig extends BaseViewModel implements ConfigTitleInfo public static COLLECTIONSTRING = Config.COLLECTIONSTRING; protected _collectionString = Config.COLLECTIONSTRING; - /* This private members are set by setConstantsInfo. */ - private _helpText: string; - private _inputType: ConfigInputType; - private _label: string; - private _choices: ConfigChoice[]; - private _defaultValue: any; - - /** - * Saves, if this config already got constants information. - */ - private _hasConstantsInfo = false; - - public get hasConstantsInfo(): boolean { - return this._hasConstantsInfo; - } - public get config(): Config { return this._model; } @@ -67,28 +20,48 @@ export class ViewConfig extends BaseViewModel implements ConfigTitleInfo return this.config.key; } - public get value(): Object { + public get value(): any { return this.config.value; } + public get data(): ConfigData | null { + return this.config.data; + } + + public get hidden(): boolean { + return !this.data; + } + public get label(): string { - return this._label; + return this.data.label; } - public get inputType(): ConfigInputType { - return this._inputType; + public get inputType(): ConfigInputType | null { + return this.data.inputType; } - public get helpText(): string { - return this._helpText; + public get helpText(): string | null { + return this.data.helpText; } - public get choices(): Object { - return this._choices; + public get choices(): ConfigChoice[] | null { + return this.data.choices; } public get defaultValue(): any { - return this._defaultValue; + return this.data.defaultValue; + } + + public get weight(): number { + return this.hidden ? 0 : this.data.weight; + } + + public get group(): string { + return this.data.group; + } + + public get subgroup(): string | null { + return this.data.subgroup; } /** @@ -105,19 +78,4 @@ export class ViewConfig extends BaseViewModel implements ConfigTitleInfo return 100; } } - - /** - * This should be called, if the constants are loaded, so all extra info can be updated. - * @param constant The constant info - */ - public setConstantsInfo(constant: ConfigConstant): void { - this._label = constant.label; - this._helpText = constant.help_text; - this._inputType = constant.input_type; - this._choices = constant.choices; - if (constant.default_value !== undefined) { - this._defaultValue = constant.default_value; - } - this._hasConstantsInfo = true; - } } diff --git a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html index d7b8daf9e..d961e0580 100644 --- a/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html +++ b/client/src/app/site/mediafiles/components/mediafile-list/mediafile-list.component.html @@ -1,4 +1,4 @@ - +

Files

diff --git a/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts b/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts index 6a1b74fa1..7c0286a37 100644 --- a/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts +++ b/client/src/app/site/motions/modules/call-list/call-list-routing.module.ts @@ -1,11 +1,11 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; +import { WatchForChangesGuard } from 'app/shared/utils/watch-for-changes.guard'; import { CallListComponent } from './call-list.component'; const routes: Routes = [ - { path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchSortingTreeGuard] } + { path: '', component: CallListComponent, pathMatch: 'full', canDeactivate: [WatchForChangesGuard] } ]; @NgModule({ diff --git a/client/src/app/site/motions/modules/category/category-routing.module.ts b/client/src/app/site/motions/modules/category/category-routing.module.ts index bff084630..a9bd8df39 100644 --- a/client/src/app/site/motions/modules/category/category-routing.module.ts +++ b/client/src/app/site/motions/modules/category/category-routing.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; -import { WatchSortingTreeGuard } from 'app/shared/utils/watch-sorting-tree.guard'; +import { WatchForChangesGuard } from 'app/shared/utils/watch-for-changes.guard'; import { CategoriesSortComponent } from './components/categories-sort/categories-sort.component'; import { CategoryDetailComponent } from './components/category-detail/category-detail.component'; import { CategoryListComponent } from './components/category-list/category-list.component'; @@ -9,8 +9,8 @@ import { CategoryMotionsSortComponent } from './components/category-motions-sort const routes: Routes = [ { path: '', component: CategoryListComponent, pathMatch: 'full' }, - { path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchSortingTreeGuard] }, - { path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchSortingTreeGuard] }, + { path: ':id/sort', component: CategoryMotionsSortComponent, canDeactivate: [WatchForChangesGuard] }, + { path: 'sort', component: CategoriesSortComponent, canDeactivate: [WatchForChangesGuard] }, { path: ':id', component: CategoryDetailComponent } ]; diff --git a/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.ts b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.ts index 60fee77af..33793cc2b 100644 --- a/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.ts +++ b/client/src/app/site/motions/modules/category/components/categories-sort/categories-sort.component.ts @@ -8,7 +8,7 @@ import { Observable } from 'rxjs'; import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { SortingTreeComponent } from 'app/shared/components/sorting-tree/sorting-tree.component'; -import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; +import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard'; import { BaseViewComponent } from 'app/site/base/base-view'; import { ViewCategory } from 'app/site/motions/models/view-category'; diff --git a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html index 4fcf847de..82864c6cd 100644 --- a/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html +++ b/client/src/app/site/motions/modules/category/components/category-list/category-list.component.html @@ -1,4 +1,4 @@ - +

Categories

diff --git a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts index 8928e7d91..9e74ffe13 100644 --- a/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts +++ b/client/src/app/site/motions/modules/category/components/category-motions-sort/category-motions-sort.component.ts @@ -11,7 +11,7 @@ import { MotionRepositoryService } from 'app/core/repositories/motions/motion-re import { ChoiceService } from 'app/core/ui-services/choice.service'; import { PromptService } from 'app/core/ui-services/prompt.service'; import { SortingListComponent } from 'app/shared/components/sorting-list/sorting-list.component'; -import { CanComponentDeactivate } from 'app/shared/utils/watch-sorting-tree.guard'; +import { CanComponentDeactivate } from 'app/shared/utils/watch-for-changes.guard'; import { BaseViewComponent } from 'app/site/base/base-view'; import { ViewCategory } from 'app/site/motions/models/view-category'; import { ViewMotion } from 'app/site/motions/models/view-motion'; diff --git a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html index 7ec3eb6c6..6b5a90f0c 100644 --- a/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html +++ b/client/src/app/site/motions/modules/motion-block/components/motion-block-list/motion-block-list.component.html @@ -1,4 +1,4 @@ - +

Motion blocks

diff --git a/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-list/motion-comment-section-list.component.html b/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-list/motion-comment-section-list.component.html index 14888740f..244339f8c 100644 --- a/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-list/motion-comment-section-list.component.html +++ b/client/src/app/site/motions/modules/motion-comment-section/components/motion-comment-section-list/motion-comment-section-list.component.html @@ -1,4 +1,4 @@ - +

Comment fields

diff --git a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html index b3953ce9a..5629c834b 100644 --- a/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html +++ b/client/src/app/site/motions/modules/motion-detail/components/motion-detail/motion-detail.component.html @@ -1,5 +1,5 @@ +

Motions

@@ -315,6 +315,14 @@ Import
+ + + + +
diff --git a/client/src/app/site/projector/components/projector-list/projector-list.component.html b/client/src/app/site/projector/components/projector-list/projector-list.component.html index 1452791cc..a84e5d3d3 100644 --- a/client/src/app/site/projector/components/projector-list/projector-list.component.html +++ b/client/src/app/site/projector/components/projector-list/projector-list.component.html @@ -1,4 +1,4 @@ - +

Projectors

diff --git a/client/src/app/site/tags/components/tag-list/tag-list.component.html b/client/src/app/site/tags/components/tag-list/tag-list.component.html index f24c9ee6a..464090f08 100644 --- a/client/src/app/site/tags/components/tag-list/tag-list.component.html +++ b/client/src/app/site/tags/components/tag-list/tag-list.component.html @@ -1,4 +1,4 @@ - +

Tags

diff --git a/client/src/app/site/topics/components/topic-detail/topic-detail.component.html b/client/src/app/site/topics/components/topic-detail/topic-detail.component.html index c678bef00..6722d1b2c 100644 --- a/client/src/app/site/topics/components/topic-detail/topic-detail.component.html +++ b/client/src/app/site/topics/components/topic-detail/topic-detail.component.html @@ -1,5 +1,5 @@ +

Groups

diff --git a/client/src/app/site/users/components/password/password.component.html b/client/src/app/site/users/components/password/password.component.html index 1d232f9e1..98a2328bf 100644 --- a/client/src/app/site/users/components/password/password.component.html +++ b/client/src/app/site/users/components/password/password.component.html @@ -1,4 +1,4 @@ -a +a

Change password

diff --git a/client/src/app/site/users/components/presence-detail/presence-detail.component.html b/client/src/app/site/users/components/presence-detail/presence-detail.component.html index 00a313a3b..b97810ee7 100644 --- a/client/src/app/site/users/components/presence-detail/presence-detail.component.html +++ b/client/src/app/site/users/components/presence-detail/presence-detail.component.html @@ -1,4 +1,4 @@ - +

Presence

diff --git a/client/src/app/site/users/components/user-detail/user-detail.component.html b/client/src/app/site/users/components/user-detail/user-detail.component.html index 826ac0f2e..3f864993b 100644 --- a/client/src/app/site/users/components/user-detail/user-detail.component.html +++ b/client/src/app/site/users/components/user-detail/user-detail.component.html @@ -1,5 +1,5 @@ +

Participants

@@ -124,9 +124,9 @@ + {{ user.is_present ? 'check_box' : 'check_box_outline_blank' }} + Present + @@ -166,7 +166,15 @@ + + + + +
@@ -199,11 +207,6 @@
- -
diff --git a/client/src/assets/styles/global-components-style.scss b/client/src/assets/styles/global-components-style.scss index e7b3bf3b2..27e6c38df 100644 --- a/client/src/assets/styles/global-components-style.scss +++ b/client/src/assets/styles/global-components-style.scss @@ -34,7 +34,8 @@ } //custom table header for search button, filtering and more. Used in ListViews - .custom-table-header { + .custom-table-header, + .background--default { background: mat-color($background, background); } diff --git a/openslides/agenda/config_variables.py b/openslides/agenda/config_variables.py index 0d0191d5a..7e8f739c6 100644 --- a/openslides/agenda/config_variables.py +++ b/openslides/agenda/config_variables.py @@ -20,7 +20,6 @@ def get_config_variables(): help_text="Input format: DD.MM.YYYY HH:MM", weight=200, group="Agenda", - subgroup="General", ) yield ConfigVariable( @@ -30,7 +29,6 @@ def get_config_variables(): label="Show subtitles in the agenda", weight=201, group="Agenda", - subgroup="General", ) # Numbering diff --git a/openslides/core/apps.py b/openslides/core/apps.py index b9b4df4a3..d736b335f 100644 --- a/openslides/core/apps.py +++ b/openslides/core/apps.py @@ -1,8 +1,6 @@ import os import sys -from collections import OrderedDict -from operator import attrgetter -from typing import Any, Dict, List +from typing import Any, Dict from django.apps import AppConfig from django.conf import settings @@ -127,8 +125,6 @@ class CoreAppConfig(AppConfig): yield self.get_model(model_name) def get_angular_constants(self): - from .config import config - constants: Dict[str, Any] = {} # Client settings @@ -147,34 +143,7 @@ class CoreAppConfig(AppConfig): pass constants["Settings"] = client_settings_dict - # Config variables - config_groups: List[Any] = [] - for config_variable in sorted( - config.config_variables.values(), key=attrgetter("weight") - ): - if config_variable.is_hidden(): - # Skip hidden config variables. Do not even check groups and subgroups. - continue - if not config_groups or config_groups[-1]["name"] != config_variable.group: - # Add new group. - config_groups.append( - OrderedDict(name=config_variable.group, subgroups=[]) - ) - if ( - not config_groups[-1]["subgroups"] - or config_groups[-1]["subgroups"][-1]["name"] - != config_variable.subgroup - ): - # Add new subgroup. - config_groups[-1]["subgroups"].append( - OrderedDict(name=config_variable.subgroup, items=[]) - ) - # Add the config variable to the current group and subgroup. - config_groups[-1]["subgroups"][-1]["items"].append(config_variable.data) - constants["ConfigVariables"] = config_groups - constants["SchemaVersion"] = schema_version_handler.get() - return constants diff --git a/openslides/core/config.py b/openslides/core/config.py index cd2f7c93e..aa3a17276 100644 --- a/openslides/core/config.py +++ b/openslides/core/config.py @@ -24,6 +24,8 @@ INPUT_TYPE_MAPPING = { "translations": list, } +ALLOWED_NONE = ("datetimepicker",) + build_key_to_id_lock = asyncio.Lock() @@ -119,12 +121,15 @@ class ConfigHandler: expected_type = INPUT_TYPE_MAPPING[config_variable.input_type] # Try to convert value into the expected datatype - try: - value = expected_type(value) - except ValueError: - raise ConfigError( - f"Wrong datatype. Expected {expected_type}, got {type(value)}." - ) + if value is None and config_variable.input_type not in ALLOWED_NONE: + raise ConfigError(f"Got None for {key}") + elif value is not None: + try: + value = expected_type(value) + except (ValueError, TypeError): + raise ConfigError( + f"Wrong datatype. Expected {expected_type}, got {type(value)}." + ) if config_variable.input_type == "choice": # Choices can be a callable. In this case call it at this place @@ -267,12 +272,14 @@ OnChangeType = Callable[[], None] ConfigVariableDict = TypedDict( "ConfigVariableDict", { - "key": str, - "default_value": Any, - "input_type": str, + "defaultValue": Any, + "inputType": str, "label": str, - "help_text": str, + "helpText": str, "choices": ChoiceType, + "weight": int, + "group": str, + "subgroup": Optional[str], }, ) @@ -314,8 +321,8 @@ class ConfigVariable: choices: ChoiceCallableType = None, hidden: bool = False, weight: int = 0, - group: str = None, - subgroup: str = None, + group: str = "General", + subgroup: str = "General", validators: ValidatorsType = None, on_change: OnChangeType = None, ) -> None: @@ -339,28 +346,26 @@ class ConfigVariable: self.choices = choices self.hidden = hidden self.weight = weight - self.group = group or "General" + self.group = group self.subgroup = subgroup self.validators = validators or () self.on_change = on_change @property - def data(self) -> ConfigVariableDict: + def data(self) -> Optional[ConfigVariableDict]: """ - Property with all data for AngularJS variable on startup. + Property with all data for Angular variable on startup. """ - return ConfigVariableDict( - key=self.name, - default_value=self.default_value, - input_type=self.input_type, - label=self.label, - help_text=self.help_text, - choices=self.choices() if callable(self.choices) else self.choices, - ) + if self.hidden: + return None - def is_hidden(self) -> bool: - """ - Returns True if the config variable is hidden so it can be removed - from response of OPTIONS request. - """ - return self.hidden + return ConfigVariableDict( + defaultValue=self.default_value, + inputType=self.input_type, + label=self.label, + helpText=self.help_text, + choices=self.choices() if callable(self.choices) else self.choices, + weight=self.weight, + group=self.group, + subgroup=self.subgroup, + ) diff --git a/openslides/core/config_variables.py b/openslides/core/config_variables.py index 74be5f693..3f1db909d 100644 --- a/openslides/core/config_variables.py +++ b/openslides/core/config_variables.py @@ -18,7 +18,6 @@ def get_config_variables(): default_value="OpenSlides", label="Event name", weight=110, - group="General", subgroup="Event", validators=(MaxLengthValidator(100),), ) @@ -28,7 +27,6 @@ def get_config_variables(): default_value="Presentation and assembly system", label="Short description of event", weight=115, - group="General", subgroup="Event", validators=(MaxLengthValidator(100),), ) @@ -38,7 +36,6 @@ def get_config_variables(): default_value="", label="Event date", weight=120, - group="General", subgroup="Event", ) @@ -47,7 +44,6 @@ def get_config_variables(): default_value="", label="Event location", weight=125, - group="General", subgroup="Event", ) @@ -60,7 +56,6 @@ def get_config_variables(): input_type="markupText", label="Legal notice", weight=132, - group="General", subgroup="Event", ) @@ -70,7 +65,6 @@ def get_config_variables(): input_type="markupText", label="Privacy policy", weight=132, - group="General", subgroup="Event", ) @@ -79,7 +73,6 @@ def get_config_variables(): default_value="Welcome to OpenSlides", label="Front page title", weight=134, - group="General", subgroup="Event", ) @@ -89,7 +82,6 @@ def get_config_variables(): input_type="markupText", label="Front page text", weight=136, - group="General", subgroup="Event", ) @@ -101,7 +93,6 @@ def get_config_variables(): input_type="boolean", label="Allow access for anonymous guest users", weight=138, - group="General", subgroup="System", ) @@ -110,7 +101,6 @@ def get_config_variables(): default_value="", label="Show this text on the login page", weight=140, - group="General", subgroup="System", ) @@ -129,7 +119,6 @@ def get_config_variables(): }, ), weight=141, - group="General", subgroup="System", ) @@ -140,7 +129,6 @@ def get_config_variables(): default_value=",", label="Separator used for all csv exports and examples", weight=160, - group="General", subgroup="Export", ) @@ -154,7 +142,6 @@ def get_config_variables(): {"value": "iso-8859-15", "display_name": "ISO-8859-15"}, ), weight=162, - group="General", subgroup="Export", ) @@ -169,7 +156,6 @@ def get_config_variables(): {"value": "right", "display_name": "Right"}, ), weight=164, - group="General", subgroup="Export", ) @@ -184,7 +170,6 @@ def get_config_variables(): {"value": "12", "display_name": "12"}, ), weight=166, - group="General", subgroup="Export", ) @@ -198,7 +183,6 @@ def get_config_variables(): {"value": "A5", "display_name": "DIN A5"}, ), weight=168, - group="General", subgroup="Export", ) diff --git a/openslides/core/serializers.py b/openslides/core/serializers.py index 619d8a390..c815b8f99 100644 --- a/openslides/core/serializers.py +++ b/openslides/core/serializers.py @@ -1,11 +1,13 @@ from typing import Any +from ..core.config import config from ..utils.projector import projector_slides from ..utils.rest_api import ( Field, IdPrimaryKeyRelatedField, IntegerField, ModelSerializer, + SerializerMethodField, ValidationError, ) from ..utils.validate import validate_html @@ -136,10 +138,14 @@ class ConfigSerializer(ModelSerializer): """ value = JSONSerializerField() + data = SerializerMethodField() class Meta: model = ConfigStore - fields = ("id", "key", "value") + fields = ("id", "key", "value", "data") + + def get_data(self, db_config): + return config.config_variables[db_config.key].data class ProjectorMessageSerializer(ModelSerializer): diff --git a/openslides/core/views.py b/openslides/core/views.py index 765ae6baa..a39031d1e 100644 --- a/openslides/core/views.py +++ b/openslides/core/views.py @@ -37,6 +37,7 @@ from ..utils.rest_api import ( RetrieveModelMixin, ValidationError, detail_route, + list_route, ) from .access_permissions import ( ConfigAccessPermissions, @@ -390,30 +391,46 @@ class ConfigViewSet(ModelViewSet): access_permissions = ConfigAccessPermissions() queryset = ConfigStore.objects.all() + can_manage_config = None + can_manage_logos_and_fonts = None + def check_view_permissions(self): """ Returns True if the user has required permissions. """ if self.action in ("list", "retrieve"): result = self.get_access_permissions().check_permissions(self.request.user) - elif self.action == "metadata": - # Every authenticated user can see the metadata and list or - # retrieve the config. Anonymous users can do so if they are - # enabled. - result = self.request.user.is_authenticated or anonymous_is_enabled() elif self.action in ("partial_update", "update"): - # The user needs 'core.can_manage_logos_and_fonts' for all config values - # starting with 'logo' and 'font'. For all other config values th euser needs - # the default permissions 'core.can_manage_config'. - pk = self.kwargs["pk"] - if pk.startswith("logo") or pk.startswith("font"): - result = has_perm(self.request.user, "core.can_manage_logos_and_fonts") - else: - result = has_perm(self.request.user, "core.can_manage_config") + result = self.check_config_permission(self.kwargs["pk"]) + elif self.action == "reset_groups": + result = has_perm(self.request.user, "core.can_manage_config") + elif self.action == "bulk_update": + result = True # will be checked in the view else: result = False return result + def check_config_permission(self, key): + """ + Checks the permissions for one config key. + Users needs 'core.can_manage_logos_and_fonts' for all config values starting + with 'logo' and 'font'. For all other config values the user needs the default + permissions 'core.can_manage_config'. + The result is cached for one request to reduce has_perm queries in e.g. bulk updates. + """ + if key.startswith("logo") or key.startswith("font"): + if self.can_manage_logos_and_fonts is None: + self.can_manage_logos_and_fonts = has_perm( + self.request.user, "core.can_manage_logos_and_fonts" + ) + return self.can_manage_logos_and_fonts + else: + if self.can_manage_config is None: + self.can_manage_config = has_perm( + self.request.user, "core.can_manage_config" + ) + return self.can_manage_config + def update(self, request, *args, **kwargs): """ Updates a config variable. Only managers can do this. @@ -422,8 +439,6 @@ class ConfigViewSet(ModelViewSet): """ key = kwargs["pk"] value = request.data.get("value") - if value is None: - raise ValidationError({"detail": "Invalid input. Config value is missing."}) # Validate and change value. try: @@ -436,6 +451,60 @@ class ConfigViewSet(ModelViewSet): # Return response. return Response({"key": key, "value": value}) + @list_route(methods=["post"]) + def bulk_update(self, request): + """ + Updates many config variables: + [{key: , value: }, ...] + """ + if not isinstance(request.data, list): + raise ValidationError({"detail": "The data needs to be a list"}) + + for entry in request.data: + key = entry.get("key") + if not isinstance(key, str): + raise ValidationError({"detail": "The key must be a string."}) + if not config.exists(key): + raise ValidationError( + {"detail": "The key {0} does not exist.", "args": [key]} + ) + if not self.check_config_permission(key): + self.permission_denied(request, message=key) + if "value" not in entry: + raise ValidationError( + {"detail": "Invalid input. Config value is missing."} + ) + + errors = {} + for entry in request.data: + try: + config[entry["key"]] = entry["value"] + except ConfigError as err: + errors[entry["key"]] = str(err) + + return Response({"errors": errors}) + + @list_route(methods=["post"]) + def reset_groups(self, request): + """ + Resets multiple groups. The request data contains all + (main) group names: [, ...] + """ + if not isinstance(request.data, list): + raise ValidationError("The data must be a list") + for group in request.data: + if not isinstance(group, str): + raise ValidationError("Every group must be a string") + + for key, config_variable in config.config_variables.items(): + if ( + config_variable.group in request.data + and config[key] != config_variable.default_value + ): + config[key] = config_variable.default_value + + return Response() + class ProjectorMessageViewSet(ModelViewSet): """ diff --git a/openslides/motions/config_variables.py b/openslides/motions/config_variables.py index 5553e38d2..77095cf6c 100644 --- a/openslides/motions/config_variables.py +++ b/openslides/motions/config_variables.py @@ -35,7 +35,6 @@ def get_config_variables(): choices=get_workflow_choices, weight=310, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -46,7 +45,6 @@ def get_config_variables(): choices=get_workflow_choices, weight=312, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -55,7 +53,6 @@ def get_config_variables(): label="Motion preamble", weight=320, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -70,7 +67,6 @@ def get_config_variables(): ), weight=322, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -81,7 +77,6 @@ def get_config_variables(): help_text="The maximum number of characters per line. Relevant when line numbering is enabled. Min: 40", weight=323, group="Motions", - subgroup="General", validators=(MinValueValidator(40),), ) @@ -92,7 +87,6 @@ def get_config_variables(): label="Reason required for creating new motion", weight=324, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -102,7 +96,6 @@ def get_config_variables(): label="Hide motion text on projector", weight=325, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -112,7 +105,6 @@ def get_config_variables(): label="Hide reason on projector", weight=326, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -122,7 +114,6 @@ def get_config_variables(): label="Hide meta information box on projector", weight=327, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -132,7 +123,6 @@ def get_config_variables(): label="Hide recommendation on projector", weight=328, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -142,7 +132,6 @@ def get_config_variables(): label="Hide referring motions", weight=329, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -153,7 +142,6 @@ def get_config_variables(): help_text="In motion list, motion detail and PDF.", weight=330, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -163,7 +151,6 @@ def get_config_variables(): help_text="Will be displayed as label before selected recommendation. Use an empty value to disable the recommendation system.", weight=332, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -173,7 +160,6 @@ def get_config_variables(): help_text="Will be displayed as label before selected recommendation in statute amendments.", weight=333, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -189,7 +175,6 @@ def get_config_variables(): ), weight=334, group="Motions", - subgroup="General", ) yield ConfigVariable( @@ -203,7 +188,6 @@ def get_config_variables(): ), weight=335, group="Motions", - subgroup="General", ) # Numbering diff --git a/openslides/users/config_variables.py b/openslides/users/config_variables.py index c1b5ea112..72c474f72 100644 --- a/openslides/users/config_variables.py +++ b/openslides/users/config_variables.py @@ -23,7 +23,6 @@ def get_config_variables(): ), weight=510, group="Participants", - subgroup="General", ) yield ConfigVariable( @@ -33,7 +32,6 @@ def get_config_variables(): label="Enable participant presence view", weight=511, group="Participants", - subgroup="General", ) # PDF diff --git a/tests/integration/core/test_views.py b/tests/integration/core/test_views.py index 27a996ce7..1f5d014b8 100644 --- a/tests/integration/core/test_views.py +++ b/tests/integration/core/test_views.py @@ -223,7 +223,7 @@ class ConfigViewSet(TestCase): ) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual( - response.data, {"detail": "Invalid input. Config value is missing."} + response.data, {"detail": "Got None for test_var_Xeiizi7ooH8Thuk5aida"} ) diff --git a/tests/integration/core/test_viewset.py b/tests/integration/core/test_viewset.py index ae862f7c9..2dd73fccb 100644 --- a/tests/integration/core/test_viewset.py +++ b/tests/integration/core/test_viewset.py @@ -1,4 +1,9 @@ +import random +import string + import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient @@ -6,6 +11,7 @@ from rest_framework.test import APIClient from openslides.core.config import config from openslides.core.models import Projector, Tag from openslides.users.models import User +from openslides.utils.auth import get_group_model from openslides.utils.autoupdate import inform_changed_data from openslides.utils.test import TestCase from tests.common_groups import GROUP_ADMIN_PK, GROUP_DELEGATE_PK @@ -187,3 +193,204 @@ class Projection(TestCase): self.assertEqual(self.projector.elements, []) self.assertEqual(self.projector.elements_preview, elements) self.assertEqual(self.projector.elements_history, []) + + +class ConfigViewSet(TestCase): + """ + Tests (currently just parts) of the ProjectorViewSet. + """ + + string_config_key = "general_event_name" + """ + The config used for testing. It should accept string. + """ + logo_config_key = "logo_web_header" + + def random_string(self): + return "".join( + random.choice(string.ascii_letters + string.digits) for i in range(20) + ) + + def get_static_config_value(self): + return { + "path": f"test_path_{self.random_string()}", + "display_name": f"test_display_name_{self.random_string()}", + } + + def setUp(self): + self.client = APIClient() + self.client.login(username="admin", password="admin") + + def test_create(self): + response = self.client.post( + reverse("config-list"), {"key": "test_key_fj3f2oqsjcqpsjclqwoO"} + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertFalse(config.exists("test_key_fj3f2oqsjcqpsjclqwoO")) + + def test_delete(self): + response = self.client.delete( + reverse("config-detail", args=[self.string_config_key]) + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertTrue(config.exists(self.string_config_key)) + + def test_update(self): + response = self.client.put( + reverse("config-detail", args=[self.string_config_key]), + {"value": "test_name_39gw4cishcvev2acoqnw"}, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + config[self.string_config_key], "test_name_39gw4cishcvev2acoqnw" + ) + + def test_set_none(self): + """ + The agenda_start_event_date_time is of type "datepicker" which + can be set to None + """ + response = self.client.put( + reverse("config-detail", args=["agenda_start_event_date_time"]), + {"value": None}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(config["agenda_start_event_date_time"], None) + + def test_set_invalid_none(self): + """ + Try to set motions_identifier_min_digits to None, which should fail + """ + response = self.client.put( + reverse("config-detail", args=["motions_identifier_min_digits"]), + {"value": None}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def degrade_admin(self, can_manage_config=False, can_manage_logos_and_fonts=False): + admin = get_user_model().objects.get(username="admin") + admin.groups.remove(GROUP_ADMIN_PK) + admin.groups.add(GROUP_DELEGATE_PK) + if can_manage_config or can_manage_logos_and_fonts: + delegate_group = get_group_model().objects.get(pk=GROUP_DELEGATE_PK) + if can_manage_config: + delegate_group.permissions.add( + Permission.objects.get( + content_type__app_label="core", codename="can_manage_config" + ) + ) + if can_manage_logos_and_fonts: + delegate_group.permissions.add( + Permission.objects.get( + content_type__app_label="core", + codename="can_manage_logos_and_fonts", + ) + ) + inform_changed_data(delegate_group) + inform_changed_data(admin) + + def test_update_no_permissions(self): + self.degrade_admin() + response = self.client.put( + reverse("config-detail", args=[self.string_config_key]), + {"value": "test_name_vp2sjjf29jswlvwaxwre"}, + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(config[self.string_config_key], "OpenSlides") + + def test_update_logo_no_config_permissions(self): + self.degrade_admin(can_manage_logos_and_fonts=True) + value = self.get_static_config_value() + response = self.client.put( + reverse("config-detail", args=[self.logo_config_key]), + {"value": value}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(config[self.logo_config_key], value) + + def test_bulk_update(self): + string_value = "test_value_k2jqvjwrorepjadvpo2J" + logo_value = self.get_static_config_value() + response = self.client.post( + reverse("config-bulk-update"), + [ + {"key": self.string_config_key, "value": string_value}, + {"key": self.logo_config_key, "value": logo_value}, + ], + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["errors"], {}) + self.assertEqual(config[self.string_config_key], string_value) + self.assertEqual(config[self.logo_config_key], logo_value) + + def test_bulk_update_no_perm(self): + self.degrade_admin() + string_value = "test_value_gjscneuqoscmqf2qow91" + response = self.client.post( + reverse("config-bulk-update"), + [{"key": self.string_config_key, "value": string_value}], + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(config[self.string_config_key], "OpenSlides") + + def test_bulk_update_no_list(self): + string_value = "test_value_fjewqpqayqfijnqm%cqi" + response = self.client.post( + reverse("config-bulk-update"), + {"key": self.string_config_key, "value": string_value}, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(config[self.string_config_key], "OpenSlides") + + def test_bulk_update_no_key(self): + string_value = "test_value_glwe32qc&Lml2lclmqmc" + response = self.client.post( + reverse("config-bulk-update"), [{"value": string_value}], format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(config[self.string_config_key], "OpenSlides") + + def test_bulk_update_no_value(self): + response = self.client.post( + reverse("config-bulk-update"), + [{"key": self.string_config_key}], + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(config[self.string_config_key], "OpenSlides") + + def test_reset_group(self): + config["general_event_name"] = "test_name_of20w2fj20clqwcm2pij" # Group General + config["agenda_show_subtitle"] = True # Group Agenda + config[ + "motions_preamble" + ] = "test_preamble_2390jvwohjwo1oigefoq" # Group motions + response = self.client.post( + reverse("config-reset-groups"), ["General", "Agenda"], format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(config["general_event_name"], "OpenSlides") + self.assertEqual(config["agenda_show_subtitle"], False) + self.assertEqual( + config["motions_preamble"], "test_preamble_2390jvwohjwo1oigefoq" + ) + + def test_reset_group_wrong_format_1(self): + response = self.client.post( + reverse("config-reset-groups"), {"wrong": "format"}, format="json" + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_reset_group_wrong_format_2(self): + response = self.client.post( + reverse("config-reset-groups"), + ["some_string", {"wrong": "format"}], + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/tests/unit/config/test_api.py b/tests/unit/config/test_api.py index c03337b6d..d5158bf1e 100644 --- a/tests/unit/config/test_api.py +++ b/tests/unit/config/test_api.py @@ -1,7 +1,8 @@ +from typing import cast from unittest import TestCase from unittest.mock import patch -from openslides.core.config import ConfigVariable, config +from openslides.core.config import ConfigVariable, ConfigVariableDict, config from openslides.core.exceptions import ConfigNotFound @@ -14,15 +15,15 @@ class TestConfigVariable(TestCase): """ config_variable = ConfigVariable("test_variable", "test_default_value") - self.assertIn( - "default_value", - config_variable.data, - "Config_varialbe.data should have a key 'default_value'", + self.assertTrue( + "defaultValue" in cast(ConfigVariableDict, config_variable.data) ) + data = config_variable.data + self.assertTrue(data) self.assertEqual( - config_variable.data["default_value"], + cast(ConfigVariableDict, config_variable.data)["defaultValue"], "test_default_value", - "The value of config_variable.data['default_value'] should be the same " + "The value of config_variable.data['defaultValue'] should be the same " "as set as second argument of ConfigVariable()", )